From 2459d0ca3716cd021cde143e372207685ce57a7a Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 13 Nov 2026 23:47:04 +0200 Subject: [PATCH] feat(editorial): stage portfolio base data and territory for atomic publication --- .../InvestmentStructureController.php | 16 +- .../InvestmentTerritoryController.php | 34 ++- app/Services/PortalEditorialWorkflow.php | 108 ++++++++- .../unification/traceability-and-manual.md | 8 + .../v2/auditoria-portal/matriz-requisitos.csv | 2 +- .../js/components/investment-territory.tsx | 6 +- .../js/pages/management/editorial-edition.tsx | 66 ++++++ resources/js/pages/structures/editor.tsx | 16 ++ resources/js/pages/structures/types.ts | 1 + .../PortfolioStructureEditorialTest.php | 213 ++++++++++++++++++ tests/frontend/editorial-edition.test.mjs | 32 +++ 11 files changed, 491 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/PortfolioStructureEditorialTest.php diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index 01b10f3d..e73c3dab 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -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.'); diff --git a/app/Http/Controllers/InvestmentTerritoryController.php b/app/Http/Controllers/InvestmentTerritoryController.php index 9323e096..e098b83e 100644 --- a/app/Http/Controllers/InvestmentTerritoryController.php +++ b/app/Http/Controllers/InvestmentTerritoryController.php @@ -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]); } } diff --git a/app/Services/PortalEditorialWorkflow.php b/app/Services/PortalEditorialWorkflow.php index 774148e5..71e23d69 100644 --- a/app/Services/PortalEditorialWorkflow.php +++ b/app/Services/PortalEditorialWorkflow.php @@ -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 $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 $fields * @param list $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); } diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 88cec36a..19997c47 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -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. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index f9c7938e..1c3559a3 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -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 diff --git a/resources/js/components/investment-territory.tsx b/resources/js/components/investment-territory.tsx index 01aa8173..580ac3ce 100644 --- a/resources/js/components/investment-territory.tsx +++ b/resources/js/components/investment-territory.tsx @@ -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.', diff --git a/resources/js/pages/management/editorial-edition.tsx b/resources/js/pages/management/editorial-edition.tsx index 03a400f4..bad7627f 100644 --- a/resources/js/pages/management/editorial-edition.tsx +++ b/resources/js/pages/management/editorial-edition.tsx @@ -18,6 +18,10 @@ type Edition = { editorial_content?: string | null; source?: string; content?: Record; + _structure?: { + fields: Record; + territory_context?: { id: string; name: string }[]; + }; }; files: { id: string; @@ -181,6 +185,68 @@ export default function EditorialEdition({ )} + {subject === 'portfolio' && + edition.payload.resource._structure && ( +
+

+ Alterações propostas nos dados-base +

+
+ {Object.entries( + edition.payload.resource._structure + .fields, + ) + .filter( + ([key]) => + ![ + 'primary_territory_id', + 'territory_version', + ].includes(key), + ) + .map(([key, value]) => ( +
+
+ {( + { + 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} +
+
+ {value ?? 'Por indicar'} +
+
+ ))} +
+ {'primary_territory_id' in + edition.payload.resource._structure + .fields && ( +

+ Localização proposta:{' '} + {edition.payload.resource._structure.territory_context + ?.map((node) => node.name) + .join(' / ') || + 'Sem localização associada'} +

+ )} +
+ )} {subject === 'portfolio' && edition.payload.links?.map((link) => (

diff --git a/resources/js/pages/structures/editor.tsx b/resources/js/pages/structures/editor.tsx index d40ef991..6f85c657 100644 --- a/resources/js/pages/structures/editor.tsx +++ b/resources/js/pages/structures/editor.tsx @@ -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.

+ {editorialDossier && ( +

+ As alterações de dados-base e localização são guardadas numa + edição privada, preservando a informação pública.{' '} + + Conferir, submeter e publicar a edição da carteira + + . +

+ )} {!record && ( 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']]; + } +} diff --git a/tests/frontend/editorial-edition.test.mjs b/tests/frontend/editorial-edition.test.mjs index 6b36df4d..95349120 100644 --- a/tests/frontend/editorial-edition.test.mjs +++ b/tests/frontend/editorial-edition.test.mjs @@ -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/); +});