feat(editorial): stage portfolio base data and territory for atomic publication

This commit is contained in:
2026-11-13 23:47:04 +02:00
parent d2c72f5e13
commit 2459d0ca37
11 changed files with 491 additions and 11 deletions
@@ -11,6 +11,7 @@ use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -48,8 +49,12 @@ class InvestmentStructureController extends Controller
{
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id) : null;
if ($record && $editorialDossier) {
$record = app(PortalEditorialWorkflow::class)->workingStructure($record, $editorialDossier);
}
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'structure' => $structure, 'record' => $record,
'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get() : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get() : [],
@@ -76,11 +81,15 @@ class InvestmentStructureController extends Controller
$isCorridor = $structure === 'corridors';
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id, true) : null;
$rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
];
if ($editorialDossier) {
$rules['editorial_version'] = ['required', 'integer', 'min:1'];
}
if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000'];
}
@@ -105,6 +114,11 @@ class InvestmentStructureController extends Controller
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
}
if ($editorialDossier) {
app(PortalEditorialWorkflow::class)->saveStructure($request->user(), $record, $editorialDossier, (int) $data['editorial_version'], $data);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Dados-base guardados numa edição privada da carteira. A publicação anterior está preservada.');
}
PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
@@ -2,8 +2,13 @@
namespace App\Http\Controllers;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\InvestmentZones;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry;
use Illuminate\Http\JsonResponse;
@@ -82,6 +87,12 @@ class InvestmentTerritoryController extends Controller
$table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
if ($dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id)) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$working = app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier);
$record->primary_territory_id = $working->getAttribute('primary_territory_id');
$record->territory_version = $working->getAttribute('territory_version');
}
$path = [];
$current = $record->primary_territory_id;
for ($i = 0; $current && $i < 5; $i++) {
@@ -93,7 +104,7 @@ class InvestmentTerritoryController extends Controller
$current = $location->parent_id;
}
return response()->json(['version' => $record->territory_version, 'path' => $path]);
return response()->json(['version' => $record->territory_version, 'path' => $path])->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, string $kind, string $id): JsonResponse
@@ -103,11 +114,20 @@ class InvestmentTerritoryController extends Controller
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
}
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$version = DB::transaction(function () use ($request, $table, $id, $data) {
$dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id, true);
$version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind) {
if ($dossier) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
}
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$effectiveVersion = (int) $record->territory_version;
if ($dossier) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$effectiveVersion = (int) app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier)->getAttribute('territory_version');
}
abort_unless($effectiveVersion === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$parent = null;
$names = [];
foreach (array_values($data['path']) as $index => $locationId) {
@@ -119,6 +139,12 @@ class InvestmentTerritoryController extends Controller
$names[] = $location->name;
}
$version = $data['version'] + 1;
if ($dossier) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveStructure($request->user(), $model::findOrFail($id), $dossier, (int) $workflow->workingPortfolio($dossier)->getAttribute('version_no'), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
$changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null;
@@ -136,6 +162,6 @@ class InvestmentTerritoryController extends Controller
return $version;
});
return response()->json(['version' => $version]);
return response()->json(['version' => $version, 'pending_publication' => $dossier !== null]);
}
}
+104 -4
View File
@@ -2,10 +2,15 @@
namespace App\Services;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -17,6 +22,12 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
*/
final class PortalEditorialWorkflow
{
private const STRUCTURE_FIELDS = [
'projects' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version'],
'economic_clusters' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version'],
'development_corridors' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version'],
];
public function available(): bool
{
return (bool) config('editorial_workflow.enabled') && Schema::hasTable('portal_editorial_editions') && Schema::hasColumn('portal_editorial_notifications', 'opportunity_id');
@@ -338,6 +349,53 @@ final class PortalEditorialWorkflow
return $copy;
}
public function structureDossier(string $structure, string $id, bool $write = false): ?InvestmentDossier
{
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$structure] ?? null;
if (! $foreign) {
return null;
}
$dossier = ($write ? InvestmentDossier::withTrashed() : InvestmentDossier::query())->where($foreign, $id)->first();
if (! $dossier) {
return null;
}
$enabled = $write ? $this->portfolioWrites($dossier->id) : $this->portfolioAvailable();
abort_if($enabled && $write && $dossier->trashed(), 409, 'A ficha associada está arquivada. Recupere o contexto antes de alterar os dados-base.');
return $enabled ? $dossier : null;
}
public function workingStructure(Model $record, InvestmentDossier $dossier): Model
{
$copy = clone $record;
$edition = $this->latestPortfolio($dossier->id);
if ($edition && $edition->published_at === null) {
$intent = $this->payload($edition)['resource']['_structure'] ?? null;
if ($intent) {
abort_unless($intent['id'] === $record->getKey() && $intent['table'] === $record->getTable(), 409);
$copy->forceFill($intent['fields']);
}
}
$copy->setAttribute('editorial_version', (int) $this->workingPortfolio($dossier)->getAttribute('version_no'));
return $copy;
}
/** @param array<string,mixed> $changes */
public function saveStructure(User $actor, Model $record, InvestmentDossier $dossier, int $expected, array $changes): \stdClass
{
$this->authorizePortfolio($actor, $dossier->id, true);
return DB::transaction(function () use ($actor, $record, $dossier, $expected, $changes) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
$edition = $this->latestPortfolio($dossier->id);
$prior = $edition && $edition->published_at === null ? ($this->payload($edition)['resource']['_structure']['fields'] ?? []) : [];
$allowed = self::STRUCTURE_FIELDS[$record->getTable()] ?? abort(422);
return $this->savePortfolio($actor, $dossier, $expected, ['_structure' => ['table' => $record->getTable(), 'id' => $record->getKey(), 'fields' => array_replace($prior, Arr::only($changes, $allowed))]]);
});
}
/** @param array<string,mixed> $fields
* @param list<int> $contributors
* @return EditorialPayload
@@ -356,6 +414,23 @@ final class PortalEditorialWorkflow
}
}
abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.');
if ($intent = $fields['_structure'] ?? null) {
abort_unless($intent['id'] === $links[0]['target_key'] && $intent['table'] === $links[0]['target_type'], 422, 'Alteração de dados-base fora do contexto da ficha.');
abort_unless(array_diff(array_keys($intent['fields']), self::STRUCTURE_FIELDS[$intent['table']]) === [], 422, 'Campos fora do âmbito da estrutura.');
if (array_key_exists('primary_territory_id', $intent['fields'])) {
$territory = $intent['fields']['primary_territory_id'];
$path = $territory ? app(InvestmentGeography::class)->path($territory, true) : [];
abort_if($territory && $path === [], 422, 'Localização proposta indisponível.');
abort_if(count($path) > 4, 422, 'Hierarquia territorial proposta inválida.');
$context = [];
foreach ($path as $index => $node) {
$location = DB::table('geo_locations')->where('id', $node->id)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first();
abort_unless($location && $location->location_type === ['province', 'district', 'admin_post', 'locality'][$index], 422, 'Hierarquia territorial proposta indisponível.');
$context[] = Arr::only(get_object_vars($location), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'external_id', 'source_code']);
}
$fields['_structure']['territory_context'] = $context;
}
}
return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]];
}
@@ -385,8 +460,11 @@ final class PortalEditorialWorkflow
'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]);
}
$base = $previous ? $this->payload($previous) : $public;
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content']), ['version_no' => $expected + 1]);
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? $base['contributors'] : []), $actor->id]));
if ($previous && $previous->published_at !== null) {
unset($base['resource']['_structure']);
}
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content', '_structure']), ['version_no' => $expected + 1]);
$contributors = array_values(array_unique([...($previous && $previous->published_at === null ? $base['contributors'] : []), $actor->id]));
$snapshot = $this->portfolioSnapshot($fields, $contributors);
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1,
@@ -400,8 +478,30 @@ final class PortalEditorialWorkflow
}
/** @param EditorialPayload $payload */
private function publishPortfolio(\stdClass $record, \stdClass $edition, array $payload): void
private function publishPortfolio(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
{
if ($intent = $payload['resource']['_structure'] ?? null) {
abort_unless(in_array($intent['table'], ['projects', 'economic_clusters', 'development_corridors'], true), 422);
$model = ['projects' => Project::class, 'economic_clusters' => EconomicCluster::class, 'development_corridors' => DevelopmentCorridor::class][$intent['table']];
$target = $model::whereKey($intent['id'])->lockForUpdate()->first();
$territories = array_column($intent['territory_context'] ?? [], 'id');
if ($territories !== []) {
DB::table('geo_locations')->whereIn('id', $territories)->orderBy('id')->lockForUpdate()->get();
}
abort_unless($target !== null && hash_equals($edition->content_hash, $this->hash($this->refreshPayload($edition, $payload))), 409, 'O contexto foi alterado. Obtenha nova aprovação.');
try {
PortalAudit::save($target, $intent['fields'], $actor->id);
if (array_key_exists('primary_territory_id', $intent['fields'])) {
DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $intent['table'], 'actor_user_id' => $actor->id, 'action' => 'primary_territory_saved',
'after_data' => json_encode(['record_id' => $intent['id'], 'territory_id' => $intent['fields']['primary_territory_id'], 'version' => $intent['fields']['territory_version']], JSON_THROW_ON_ERROR)]);
}
} catch (QueryException $exception) {
if (in_array($exception->errorInfo[0] ?? '', ['23505', '23514'], true)) {
abort(409, 'Os dados-base deixaram de ser aplicáveis ou o código já está em uso. Guarde nova edição antes de publicar.');
}
throw $exception;
}
}
$approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
$fields = Arr::only($payload['resource'], ['title', 'source', 'content']);
$fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR);
@@ -512,7 +612,7 @@ final class PortalEditorialWorkflow
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
if ($portfolio) {
$this->publishPortfolio($r, $e, $payload);
$this->publishPortfolio($actor, $r, $e, $payload);
} else {
$this->publish($actor, $r, $e, $payload);
}
@@ -1,3 +1,11 @@
## 13/11/2026 — dados-base e localização ligados à mesma edição da carteira
O recorte de carteira inclui agora os dados-base de projectos, clusters e corredores com ficha existente. Nome, código, estado operacional, descrição/sector, orçamento e datas são guardados como alterações propostas na mesma edição privada. A localização reutiliza a selecção/hierarquia territorial existente e fica igualmente proposta, com contador próprio e contexto territorial por hash. A gestão recupera os campos deliberados; a consulta pública continua a ler os valores anteriores. Publicar a edição aprovada aplica ficha, dados-base e localização numa única transacção, reutilizando as auditorias de gestão e território.
Conflitos de edição ou de código não sobrescrevem dados. Um código ocupado depois da aprovação devolve 409 e reverte ficha/dados-base em conjunto. Alterações da referência territorial invalidam a decisão; a publicação bloqueia as linhas de contexto durante a reconferência. Todos os contributos privados continuam sujeitos à independência da carteira. Desactivar o circuito ou arquivar a ficha não permite contornar a revisão. Ao iniciar outro ciclo depois de publicar/arquivar, alterações propostas antigas não são reaplicadas silenciosamente. Cadastros internos sem ficha associada conservam o comportamento anterior: este recorte não encerra o versionamento de todas as estruturas nem das suas relações/cadeias de valor.
Provas em `outputs/editorial-structures-20261113/`: 6 testes novos/178 asserções; carteira 5/199; regressão de estruturas/território 19/289; Fase 1 11/223; frontend 21 testes. PHPStan, tipos e compilação aprovados. Nenhuma nova migração deste recorte, nenhuma alteração de contas/permissões ou instalação operacional. Alterações integradas na candidata agrupada; estatística, indicadores demográficos e conteúdos territoriais continuam no saldo da Fase 2, assim como as notificações próprias de Competitividade e as estruturas sem ficha. Conferência visual pendente.
## 13/11/2026 — fichas da carteira integradas no mesmo circuito e pacote editorial
**Lacuna corrigida em QA:** guardar uma ficha de projecto âncora, cluster ou corredor deixava de a disponibilizar ao público. Com `PORTAL_EDITORIAL_PORTFOLIO_ENABLED` activo, o editor existente guarda uma edição privada e conserva integralmente o registo público, incluindo título, conteúdo, fonte, versão e datas. A confirmação independente continua obrigatória; aprovar e publicar são operações distintas. A publicação anterior só é substituída pela publicação explícita da edição aprovada. Arquivar uma edição privada preserva-a; arquivar a edição pública actual retira-a da consulta, sem apagar dados ou históricos.
@@ -11130,4 +11130,4 @@ REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzido
ACT-20261113-PESQUISA,Pesquisa rápida: contar corredores sem misturar referências; produtos físicos e financeiros separados por categoria e localização; como licenciar empresa.,Pedidos Tempo na conversa de 13/11/2026,Pesquisa rápida,/pesquisa; /,"SearchTerms::requestIntent, GlobalSearchController, FederatedSearch, portal/search, portal-hero; reutiliza catálogos publicados existentes.",Implementado em QA; nova candidata ainda não instalada.,"outputs/quick-search-20261113/: integration.log, regression.log, unit.log, frontend.log, types.log, analysis.log, build.log",QA implementado e testes focados aprovados; instalação e visual pendentes,Instalar após autorização concreta; conferir apresentação no navegador.,,Contagem tipada e zero distinto de falha; produtos separados com localização/cobertura e contexto nos links; licenciamento no roteiro/dossier existentes; só publicados; erros de fontes explícitos; paginação preservada.,Alta,,,,,,,,,,,,,,"Conferência visual e instalação desta candidata; pesquisa territorial é textual, sem inferir elegibilidade.",,,,Implementação QA concluída neste recorte; entrega preparada.,,,,,,,,,,,,,,,,,,
ACT-20261113-EDITORIAL-F1,"Dashboard Colaborativo Fase 1: versões e autorizações; artigos, legislação, oportunidades e documentos; filas e notificações",Directiva Tempo 13/11/2026 nesta sessão; plano aprovado e autorização de implementação apenas em desenvolvimento/QA,Gestão editorial / conteúdos,/management/editorial; /management/editorial/resources/{id}; edições/anexos/notificações; editores e circuito nativo de oportunidades,Edições privadas imutáveis no circuito; versão pública preservada; aprovação/publicação separadas; decisões ligadas a conteúdo/anexos; permissões e auditoria reutilizadas; notificações com acesso revogável,Desactivado por defeito fora dos testes; estrutura instalada apenas em editorial_phase1_20261113_test:55441,outputs/editorial-phase1-20261113: integração 11/223; oportunidades 6/116 + caso reforçado 1/20; unidade 1/6; frontend 12; PHPStan/tipos/build aprovados,Implementado em QA; conferência visual e instalação operacional pendentes,Conferência visual; configuração operacional de designações e independência; instalação/activação fora do QA não executadas,Decisão concreta para instalação operacional e designações; não bloqueia as provas sintéticas QA,Editar preserva toda a publicação; JSON/prévias/anexos autorizados; conteúdo/anexos novos exigem nova decisão; aprovação distinta da publicação; filas e notificações isoladas; conflitos 409; histórico recuperável,Alta,,,,,"Fila, filtros, edição, histórico e notificações em QA","JSON nas mesmas rotas privadas, com as mesmas autorizações","Edições, decisões, notificações e auditoria; dados sintéticos revertidos pelos testes",Versões e publicação anterior recuperadas; isolamento/revogação demonstrados,Versão pública mantida durante edição e revisão; substituição só por publicação explícita,,,,"Implementação focada em lacunas, sem segundo motor de oportunidades ou alteração de permissões",Conferência visual; instalação operacional separada; Fase 2 não implementada,"DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",,Implementar em desenvolvimento/QA; não intervir em produção,Implementação aditiva e testes focados,,Recibos QA e testes em outputs/editorial-phase1-20261113; manual unificado actualizado,Dashboard Colaborativo Fase 1,13/11/2026,,,,,,,,"Reutilização dos testes nativos de oportunidade, permissão, prévias e integridade",Versão pública preservada; circuito editorial e decisões por versão/anexos; escopos de notificações e conteúdos,Sem conferência visual; sem operação em homologação/produção; tentativa inicial descartada explicitamente,,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,QA demonstrado; instalação não executada; Fase 2 pendente
ACT-20261113-EDITORIAL-CLUSTERS,"Separar aprovação/publicação da análise de Competitividade; preservar publicação, contexto, versões e autoridade; agrupar entrega relacionada","Directiva de continuidade e decisão de pacote conjunto do proprietário, 13/11/2026; Fase 2 já delimitada",Dashboard Colaborativo / Competitividade dos clusters,/management/clusters; /management/clusters/{id}/competitiveness/{action}; Gestão de conteúdos; Circuito editorial,"Extensão do circuito cifrado existente: submissão, revisão, aprovação e publicação distintas; devolução/rejeição/arquivo; versões/contexto; MFA e autoridade efectiva; navegação reutilizada",QA isolado; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados",Recorte implementado em QA; não encerra a Fase 2,Conferência visual; notificações dos clusters; versionamento dos dados-base e outros conteúdos Fase 2; preparação/instalação operacional,Instalação agrupada exige preparar e autorizar schema/contrato da Fase 1 no destino; não bloqueia QA,Aprovar não publica; rascunho/recuperação preservam publicação; alteração do contexto invalida aprovação; independência quando exigida; revogação e conflitos bloqueados; retirar publicação preserva revisão privada,Alta,,,,,Editor e lista existentes com estados distintos; entradas de gestão integradas,"JSON autenticado, MFA e autorizações verificadas no servidor",Histórico cifrado existente; sem novas tabelas ou migrações deste recorte,Histórico recuperado como nova edição; publicação anterior preservada,Publicação só por confirmação distinta após aprovação da edição exacta,,,,Provas sintéticas do delta e regressão afectada; provas anteriores reutilizadas,Conferência visual; notificações clusters e restantes conteúdos Fase 2; instalação conjunta,"ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Implementação localizada sem migração; pacote Git agrupado,,outputs/editorial-clusters-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,Regressão anterior de Competitividade e Fase 1; oportunidades mantêm provas aceites,"Ciclo editorial de Competitividade, preservação pública, permissões, independência, recuperação e contexto","Sem conferência visual ou instalação operacional; não prova versão editorial dos dados-base, notificações clusters nem restantes conteúdos Fase 2",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,QA demonstrado; entrega agrupada preparada; instalação pendente
ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas públicas de projecto âncora/cluster/corredor enquanto novas versões seguem o circuito editorial; integrar pacote conjunto,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados,Fichas da carteira implementadas em QA; dados-base e restante Fase 2 pendentes,Conferência visual; preparação/instalação operacional; versionamento de dados-base e restantes conteúdos Fase 2,Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Dados-base, relações, estatística/demografia/território; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; não prova versionamento dos dados-base nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente
ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas e dados-base/localização associados de projecto âncora/cluster/corredor durante revisão privada; publicação conjunta e pacote agrupado,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações; dados-base e localização propostos na mesma edição; publicação atómica; conflitos sem sobrescrever,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados; dados-base novos 6/178, estruturas/território 19/289, frontend actualizado 21; outputs/editorial-structures-20261113",Fichas e dados-base/localização associados implementados em QA; estruturas sem ficha e restantes conteúdos Fase 2 pendentes,"Conferência visual; preparação/instalação operacional; estruturas sem ficha, relações/cadeias e restantes conteúdos Fase 2",Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis; dados-base/localização e ficha aplicados atomicamente; precisão do orçamento; conflitos de código e território sem publicação parcial",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Estruturas sem ficha, relações/cadeias, estatística/demografia/território; notificações Competitividade; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado; dados-base em outputs/editorial-structures-20261113,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; recorte de dados-base apenas para entidades com ficha; não encerra estruturas sem ficha/relações nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente
Can't render this file because it is too large.
@@ -441,7 +441,11 @@ export default function InvestmentTerritory({
},
);
setVersion(data.version);
setMessage('Localização associada e guardada.');
setMessage(
data.pending_publication
? 'Localização guardada na edição privada para revisão; a publicação anterior está preservada.'
: 'Localização associada e guardada.',
);
} catch (e) {
setMessage(
e instanceof Error ? e.message : 'Erro ao guardar.',
@@ -18,6 +18,10 @@ type Edition = {
editorial_content?: string | null;
source?: string;
content?: Record<string, string | null>;
_structure?: {
fields: Record<string, string | number | null>;
territory_context?: { id: string; name: string }[];
};
};
files: {
id: string;
@@ -181,6 +185,68 @@ export default function EditorialEdition({
</dl>
</>
)}
{subject === 'portfolio' &&
edition.payload.resource._structure && (
<section className="rounded border p-3">
<h3 className="font-semibold">
Alterações propostas nos dados-base
</h3>
<dl>
{Object.entries(
edition.payload.resource._structure
.fields,
)
.filter(
([key]) =>
![
'primary_territory_id',
'territory_version',
].includes(key),
)
.map(([key, value]) => (
<div key={key}>
<dt className="font-semibold">
{(
{
name: 'Designação',
code: 'Código',
status: 'Estado operacional',
sector: 'Sector',
description:
'Descrição',
budget_amount:
'Orçamento',
budget_currency:
'Moeda',
start_date:
'Data inicial',
end_date:
'Data final',
} as Record<
string,
string
>
)[key] ?? key}
</dt>
<dd>
{value ?? 'Por indicar'}
</dd>
</div>
))}
</dl>
{'primary_territory_id' in
edition.payload.resource._structure
.fields && (
<p>
Localização proposta:{' '}
{edition.payload.resource._structure.territory_context
?.map((node) => node.name)
.join(' / ') ||
'Sem localização associada'}
</p>
)}
</section>
)}
{subject === 'portfolio' &&
edition.payload.links?.map((link) => (
<p key={link.target_key}>
+16
View File
@@ -78,7 +78,9 @@ export default function Editor({
chainLinks,
gaps,
competitiveness,
editorialDossier,
}: {
editorialDossier?: string | null;
structure: Structure;
record: StructureRecord | null;
projects: { id: string; name: string; code: string }[];
@@ -111,6 +113,7 @@ export default function Editor({
? 'cluster'
: 'corredor';
const form = useForm({
editorial_version: record?.editorial_version ?? null,
code: record?.code ?? '',
name: record?.name ?? '',
status:
@@ -146,6 +149,19 @@ export default function Editor({
Registo interno de gestão. O estado activo não publica este
conteúdo no portal.
</p>
{editorialDossier && (
<p className="rounded border p-3">
As alterações de dados-base e localização são guardadas numa
edição privada, preservando a informação pública.{' '}
<Link
className="underline"
href={`/management/editorial/portfolio/${editorialDossier}`}
>
Conferir, submeter e publicar a edição da carteira
</Link>
.
</p>
)}
{!record && (
<TerritoryFields
endpoint="/management/territory-options"
+1
View File
@@ -10,6 +10,7 @@ export type StructureRecord = {
budget_currency?: string | null;
start_date?: string | null;
end_date?: string | null;
editorial_version?: number;
};
export const titles = {
projects: 'Projectos',
@@ -0,0 +1,213 @@
<?php
namespace Tests\Feature;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Project;
use App\Models\User;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class PortfolioStructureEditorialTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $editor;
private User $reviewer;
private Model $entity;
private InvestmentDossier $dossier;
private string $kind;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
[$this->editor, $this->reviewer] = User::factory()->count(2)->create()->all();
config(['portal.managers' => [$this->editor->email, $this->reviewer->email], 'portal.profile_access_enabled' => false,
'editorial_workflow.enabled' => true, 'editorial_workflow.portfolio' => true]);
$this->fixture('projects');
}
private function fixture(string $kind): void
{
$this->kind = $kind;
$class = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind];
$this->entity = $class::create(['code' => 'STRUCT-QA-'.bin2hex(random_bytes(5)), 'name' => 'Designação pública original', 'status' => 'active',
...($kind === 'clusters' ? ['sector' => 'Energia'] : ['description' => 'Descrição original']),
...($kind === 'projects' ? ['budget_amount' => '1500.25', 'budget_currency' => 'MZN'] : [])]);
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind];
$this->dossier = InvestmentDossier::create([$foreign => $this->entity->getKey(), 'title' => 'Ficha sintética de dados-base', 'source' => 'Fonte sintética QA', 'content' => ['description' => 'Conteúdo público original'],
'version_no' => 1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética de demonstração', 'published_at' => now()]);
}
private function fields(int $expected = 1): array
{
return ['editorial_version' => $expected, 'code' => $this->entity->getAttribute('code'), 'name' => 'Nova designação privada', 'status' => 'active',
...($this->kind === 'clusters' ? ['sector' => 'Indústria'] : ['description' => 'Descrição privada revista']),
...($this->kind === 'projects' ? ['budget_amount' => '999999999999999999.99', 'budget_currency' => 'USD'] : [])];
}
private function save(int $expected = 1): \stdClass
{
$this->actingAsWithMfa($this->editor);
$this->put('/management/'.$this->kind.'/'.$this->entity->getKey(), $this->fields($expected))->assertRedirect()->assertSessionHasNoErrors();
return app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id);
}
private function action(\stdClass $edition, string $action, int $status = 200): \stdClass
{
$actor = $action === 'submit' ? $this->editor : $this->reviewer;
$this->actingAsWithMfa($actor);
$this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão fundamentada de dados-base em QA'])->assertStatus($status);
return DB::table('portal_editorial_editions')->where('id', $edition->id)->first();
}
private function approve(\stdClass $edition): \stdClass
{
foreach (['submit', 'start_review', 'approve'] as $action) {
$edition = $this->action($edition, $action);
}
return $edition;
}
public function test_project_cluster_and_corridor_base_data_and_fiche_are_published_atomically_without_precision_loss(): void
{
foreach (['projects', 'clusters', 'corridors'] as $kind) {
if ($kind !== 'projects') {
$this->fixture($kind);
}
$before = $this->entity->fresh()->getAttributes();
$this->actingAsWithMfa($this->editor)->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 1, 'title' => 'Título privado deliberado', 'source' => 'Fonte QA', 'content' => ['description' => 'Texto deliberado em paralelo']])->assertRedirect();
$edition = $this->save(2);
$this->assertSame($before, $this->entity->fresh()->getAttributes());
$this->get('/management/'.$kind.'/'.$this->entity->getKey().'/edit')->assertInertia(fn (Assert $p) => $p->where('record.name', 'Nova designação privada')->where('record.editorial_version', 3)->where('editorialDossier', $this->dossier->id));
$edition = $this->approve($edition);
$this->assertSame($before, $this->entity->fresh()->getAttributes());
$this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Conteúdo público original'));
$this->action($edition, 'publish');
$this->assertSame('Nova designação privada', $this->entity->fresh()->getAttribute('name'));
$this->assertSame('Texto deliberado em paralelo', $this->dossier->fresh()->content['description']);
if ($kind === 'projects') {
$this->assertSame('999999999999999999.99', $this->entity->fresh()->getAttribute('budget_amount'));
$this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('anchorProject.name', 'Nova designação privada')->where('anchorProject.budget_currency', 'USD'));
}
$this->assertGreaterThan(0, DB::table('audit_events')->where('source_system', 'portal-management')->where('entity_id', $this->entity->getKey())->count());
if ($kind === 'corridors') {
$this->action(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id), 'archive');
$this->entity->update(['name' => 'Actualização de contexto após arquivo']);
$this->get('/management/corridors/'.$this->entity->getKey().'/edit')->assertInertia(fn (Assert $p) => $p->where('record.name', 'Actualização de contexto após arquivo'));
}
$this->actingAsWithMfa($this->editor)->put('/management/portfolio/'.$this->dossier->id, ['version_no' => $this->dossier->fresh()->version_no, 'title' => 'Outra edição', 'source' => 'Fonte QA', 'content' => ['description' => 'Outra alteração narrativa']])->assertRedirect();
$next = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id);
$this->assertArrayNotHasKey('_structure', app(PortalEditorialWorkflow::class)->payload($next)['resource']);
}
}
public function test_territory_is_staged_recovered_and_versioned_without_changing_the_public_location(): void
{
[$province, $district] = $this->locations();
$url = '/management/territories/projects/'.$this->entity->getKey();
$this->actingAsWithMfa($this->editor);
$this->postJson($url, ['version' => 0, 'path' => [$province, $district]])->assertOk()->assertJsonPath('version', 1)->assertJsonPath('pending_publication', true);
$this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id'));
$this->getJson($url)->assertOk()->assertJsonPath('version', 1)->assertJsonPath('path.0.id', $district);
$this->postJson($url, ['version' => 0, 'path' => [$province]])->assertConflict();
$this->put('/management/projects/'.$this->entity->getKey(), $this->fields(1))->assertConflict();
$edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id));
$this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id'));
$this->action($edition, 'publish');
$this->assertSame($district, $this->entity->fresh()->getAttribute('primary_territory_id'));
$this->assertSame(1, (int) $this->entity->fresh()->getAttribute('territory_version'));
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'investment-territory')->where('after_data->record_id', $this->entity->getKey())->count());
$this->actingAsWithMfa($this->editor)->postJson($url, ['version' => 1, 'path' => []])->assertOk()->assertJsonPath('pending_publication', true);
$this->assertSame($district, $this->entity->fresh()->getAttribute('primary_territory_id'));
$edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id));
$this->action($edition, 'publish');
$this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id'));
}
public function test_conflicting_code_after_approval_rolls_back_base_data_and_fiche_together(): void
{
$data = $this->fields();
$data['code'] = 'CONFLICT-QA-'.bin2hex(random_bytes(4));
$this->actingAsWithMfa($this->editor)->put('/management/projects/'.$this->entity->getKey(), $data)->assertRedirect();
$edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id));
Project::create(['code' => $data['code'], 'name' => 'Outro registo sintético', 'status' => 'planned']);
$before = $this->entity->fresh()->getAttributes();
$fiche = $this->dossier->fresh()->getAttributes();
$this->action($edition, 'publish', 409);
$this->assertSame($before, $this->entity->fresh()->getAttributes());
$this->assertSame($fiche, $this->dossier->fresh()->getAttributes());
$this->assertSame('approved', app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id)->state);
}
public function test_changed_territory_evidence_requires_a_new_approval_and_cross_context_intents_are_rejected(): void
{
[$province, $district] = $this->locations();
$this->actingAsWithMfa($this->editor)->postJson('/management/territories/projects/'.$this->entity->getKey(), ['version' => 0, 'path' => [$province, $district]])->assertOk();
$edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id));
DB::table('geo_locations')->where('id', $district)->update(['name' => 'Localização revista']);
$this->action($edition, 'publish', 409);
$other = Project::create(['code' => 'OTHER-QA-'.bin2hex(random_bytes(4)), 'name' => 'Outro contexto', 'status' => 'planned']);
$this->actingAsWithMfa($this->editor);
try {
app(PortalEditorialWorkflow::class)->saveStructure($this->editor, $other, $this->dossier, 2, ['name' => 'Não aplicar']);
$this->fail('Uma alteração não pode migrar para outro contexto.');
} catch (HttpException $exception) {
$this->assertSame(422, $exception->getStatusCode());
}
$this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id'));
}
public function test_missing_mfa_and_disabling_cannot_bypass_private_structure_editions(): void
{
$this->actingAsWithMfa($this->editor);
session()->forget('portal.mfa_at');
$this->put('/management/projects/'.$this->entity->getKey(), $this->fields())->assertForbidden();
$this->save();
config(['editorial_workflow.portfolio' => false]);
$this->put('/management/projects/'.$this->entity->getKey(), $this->fields(2))->assertStatus(503);
$this->postJson('/management/territories/projects/'.$this->entity->getKey(), ['version' => 0, 'path' => []])->assertStatus(503);
$this->assertSame('Designação pública original', $this->entity->fresh()->getAttribute('name'));
}
public function test_archived_fiche_cannot_be_used_to_bypass_base_data_review(): void
{
$this->save();
$this->dossier->delete();
$this->put('/management/projects/'.$this->entity->getKey(), $this->fields(2))->assertConflict();
$this->assertSame('Designação pública original', $this->entity->fresh()->getAttribute('name'));
}
private function locations(): array
{
$parent = null;
$ids = [];
foreach (['country', 'province', 'district'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Local sintético '.$type, 'location_type' => $type, 'parent_id' => $parent, 'data_classification' => 'public']);
$parent = $id;
$ids[$type] = $id;
}
return [$ids['province'], $ids['district']];
}
}
+32
View File
@@ -204,3 +204,35 @@ test('portfolio contributor cannot select publication of an approved edition', (
/value="publish"/,
);
});
test('portfolio review presents proposed base data and territory without applying them', () => {
const html = render({
subject: 'portfolio',
edition: {
...props.edition,
payload: {
...props.edition.payload,
resource: {
title: 'Ficha existente',
_structure: {
fields: {
name: 'Nome proposto',
budget_amount: '999999999999999999.99',
primary_territory_id: 'qa-district',
territory_version: 1,
},
territory_context: [
{ id: 'qa-district', name: 'Distrito sintético' },
],
},
},
files: [],
},
},
});
assert.match(html, /Alterações propostas nos dados-base/);
assert.match(html, /Nome proposto/);
assert.match(html, /999999999999999999.99/);
assert.match(html, /Localização proposta: Distrito sintético/);
assert.doesNotMatch(html, /qa-district|territory_version/);
});