diff --git a/app/Http/Controllers/ClusterValueChainController.php b/app/Http/Controllers/ClusterValueChainController.php index 9d199b4e..bda741c4 100644 --- a/app/Http/Controllers/ClusterValueChainController.php +++ b/app/Http/Controllers/ClusterValueChainController.php @@ -7,6 +7,8 @@ use App\Models\InvestmentGap; use App\Models\ValueChain; use App\Models\ValueChainLink; use App\Services\PortalAudit; +use App\Services\PortalEditorialSubjects; +use App\Services\PortalEditorialWorkflow; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -21,6 +23,17 @@ class ClusterValueChainController extends Controller 'to_value_chain_id' => ['required', 'uuid', 'different:from_value_chain_id', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')], 'link_type' => ['required', Rule::in(['supplies', 'processes', 'distributes', 'supports'])], ]); + if (app(PortalEditorialSubjects::class)->available()) { + $edition = DB::transaction(function () use ($cluster, $data, $request) { + EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); + $link = ValueChainLink::where($data)->first(); + abort_if($link !== null, 409, 'Ligação existente. Abra a edição desse registo para alterar.'); + + return app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chain-links', new ValueChainLink, $data, 0); + }); + + return redirect('/management/editorial/subjects/value-chain-links/'.$edition->subject_key)->with('success', 'Ligação guardada numa proposta privada.'); + } DB::transaction(function () use ($cluster, $data, $request) { EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); $link = ValueChainLink::where($data)->first() ?? new ValueChainLink; @@ -32,6 +45,13 @@ class ClusterValueChainController extends Controller public function removeLink(Request $request, EconomicCluster $cluster, ValueChainLink $link): RedirectResponse { + if (app(PortalEditorialSubjects::class)->writes('value-chain-links', $link->id)) { + abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404); + $workflow = app(PortalEditorialWorkflow::class); + $workflow->saveSubject($request->user(), 'value-chain-links', $link->id, $workflow->subjectVersion('value-chain-links', $link->id), [], false, true); + + return redirect('/management/editorial/subjects/value-chain-links/'.$link->id)->with('success', 'Retirada proposta. A ligação actual mantém-se até à publicação da decisão.'); + } DB::transaction(function () use ($cluster, $link, $request) { EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404); @@ -44,6 +64,7 @@ class ClusterValueChainController extends Controller public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse { abort_if($gap && $gap->cluster_id !== $cluster->id, 404); + $editorial = $gap ? app(PortalEditorialSubjects::class)->writes('investment-gaps', $gap->id) : app(PortalEditorialSubjects::class)->available(); $data = $request->validate([ 'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')], 'title' => ['required', 'string', 'max:200'], @@ -52,7 +73,13 @@ class ClusterValueChainController extends Controller 'rationale' => ['required', 'string', 'min:10', 'max:5000'], 'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])], 'call_to_action' => ['required', 'string', 'max:2000'], + ...($editorial && $gap ? ['editorial_version' => ['required', 'integer', 'min:1']] : []), ]); + if ($editorial) { + $edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'investment-gaps', $gap ?? new InvestmentGap, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $gap ? (int) $data['editorial_version'] : 0); + + return redirect('/management/editorial/subjects/investment-gaps/'.$edition->subject_key)->with('success', 'Lacuna guardada numa proposta privada.'); + } PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id); return back()->with('success', 'Lacuna de investimento guardada.'); diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index e73c3dab..2a39203b 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\PortalEditorialSubjects; use App\Services\PortalEditorialWorkflow; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -52,12 +53,15 @@ class InvestmentStructureController extends Controller $editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id) : null; if ($record && $editorialDossier) { $record = app(PortalEditorialWorkflow::class)->workingStructure($record, $editorialDossier); + } elseif ($record && app(PortalEditorialSubjects::class)->available()) { + $record = clone $record; + $record->forceFill(get_object_vars(app(PortalEditorialSubjects::class)->working($structure, (object) $record->getAttributes()))); } - return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'structure' => $structure, 'record' => $record, + return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'editorialSubject' => ! $editorialDossier && $record && app(PortalEditorialSubjects::class)->available() ? '/management/editorial/subjects/'.$structure.'/'.$id : null, 'structuralEditorial' => app(PortalEditorialSubjects::class)->available(), '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() : [], + 'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('value-chains', (object) $row->getAttributes()) : $row) : [], + 'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('investment-gaps', (object) $row->getAttributes()) : $row) : [], 'chainLinks' => $structure === 'clusters' && $id ? DB::table('value_chain_links') ->join('value_chains as origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id') ->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_value_chain_id') @@ -70,7 +74,7 @@ class InvestmentStructureController extends Controller 'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects') ->join('projects', 'projects.id', '=', 'cluster_projects.project_id') ->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at') - ->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [], + ->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name'])->map(fn ($row) => app(PortalEditorialSubjects::class)->working('cluster-project', $row)) : [], ]); } @@ -82,12 +86,13 @@ class InvestmentStructureController extends Controller $model = $this->model($structure); $record = $id ? $model::findOrFail($id) : new $model; $editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id, true) : null; + $subjectWrites = ! $editorialDossier && ($id ? app(PortalEditorialSubjects::class)->writes($structure, $id) : app(PortalEditorialSubjects::class)->available()); $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) { + if ($editorialDossier || ($subjectWrites && $id)) { $rules['editorial_version'] = ['required', 'integer', 'min:1']; } if ($isProject || $isCorridor) { @@ -119,6 +124,17 @@ class InvestmentStructureController extends Controller return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Dados-base guardados numa edição privada da carteira. A publicação anterior está preservada.'); } + if ($subjectWrites) { + DB::transaction(function () use ($request, $record, $structure, $id, $data): void { + $fields = array_diff_key($data, ['editorial_version' => true]); + if (! $id) { + PortalAudit::save($record, $fields, $request->user()->id); + } + app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $structure, (string) $record->getKey(), $id ? (int) $data['editorial_version'] : 0, $fields, ! $id); + }); + + return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Proposta privada guardada. Reveja a edição antes de publicar as alterações.'); + } PortalAudit::save($record, $data, $request->user()->id); return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.'); @@ -137,6 +153,7 @@ class InvestmentStructureController extends Controller public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse { + $editorial = app(PortalEditorialSubjects::class)->available(); $data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')], 'version_no' => ['required', 'integer', 'min:0'], 'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])], @@ -144,10 +161,21 @@ class InvestmentStructureController extends Controller 'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'], 'valid_from' => ['required', 'date_format:Y-m-d'], - 'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]); - DB::transaction(function () use ($cluster, $data, $request) { + 'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'], + ...($editorial ? ['editorial_version' => ['required', 'integer', 'min:0']] : [])]); + DB::transaction(function () use ($cluster, $data, $request, $editorial) { EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); $link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first(); + if ($editorial || ($link && app(PortalEditorialSubjects::class)->writes('cluster-project', $link->id))) { + abort_unless((int) $data['version_no'] === ($link ? (int) $link->version_no : 0), 409, 'A associação foi alterada. Confira a versão actual.'); + $fields = [...array_diff_key($data, ['version_no' => true, 'editorial_version' => true]), 'cluster_id' => $cluster->id]; + if (! $link) { + $fields += ['validation_status' => 'pending', 'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1]; + } + app(PortalEditorialSubjects::class)->stageModel($request->user(), 'cluster-project', $link ?? new ClusterProject, $fields, (int) $data['editorial_version']); + + return; + } if ($link) { abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.'); $changed = false; @@ -178,10 +206,17 @@ class InvestmentStructureController extends Controller public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse { abort_if($chain && $chain->cluster_id !== $cluster->id, 404); + $editorial = $chain ? app(PortalEditorialSubjects::class)->writes('value-chains', $chain->id) : app(PortalEditorialSubjects::class)->available(); $data = $request->validate(['code' => ['required', 'string', 'max:100', Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)], 'name' => ['required', 'string', 'max:200'], - 'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])]]); + 'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])], + ...($editorial && $chain ? ['editorial_version' => ['required', 'integer', 'min:1']] : [])]); + if ($editorial) { + $edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chains', $chain ?? new ValueChain, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $chain ? (int) $data['editorial_version'] : 0); + + return redirect('/management/editorial/subjects/value-chains/'.$edition->subject_key)->with('success', 'Etapa guardada numa proposta privada.'); + } PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id); return back()->with('success', 'Etapa da cadeia de valor guardada.'); diff --git a/app/Http/Controllers/InvestmentStructureRelationController.php b/app/Http/Controllers/InvestmentStructureRelationController.php index bb7f6520..d1527afd 100644 --- a/app/Http/Controllers/InvestmentStructureRelationController.php +++ b/app/Http/Controllers/InvestmentStructureRelationController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Services\PortalEditorialSubjects; +use App\Services\PortalEditorialWorkflow; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -30,7 +32,8 @@ class InvestmentStructureRelationController extends Controller ...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []), ]); - DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void { + $proposal = null; + DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request, $kind, &$proposal): void { DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail(); $existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first(); abort_if($existing !== null, 409, 'Associacao ja registada.'); @@ -40,8 +43,15 @@ class InvestmentStructureRelationController extends Controller $id = (string) Str::uuid(); DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']); $this->audit($request, $table, $id, 'submitted', 1); + if (app(PortalEditorialSubjects::class)->available()) { + $proposal = app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $kind, $id, 0, $data, true); + } }); + if ($proposal) { + return redirect('/management/editorial/subjects/'.$kind.'/'.$proposal->subject_key)->with('success', 'Associação guardada numa proposta privada.'); + } + return back()->with('success', 'Associacao submetida para revisao independente.'); } @@ -50,6 +60,12 @@ class InvestmentStructureRelationController extends Controller abort_unless(in_array($action, ['validate', 'revoke'], true), 404); [$table] = $this->definition($kind); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); + if (app(PortalEditorialSubjects::class)->writes($kind, $id)) { + app(PortalEditorialSubjects::class)->authorize($request->user(), $kind, $id, true); + abort_if($request->expectsJson(), 409, 'Continue em /management/editorial/subjects/'.$kind.'/'.$id.' para rever a edição e publicar a decisão separadamente.'); + + return redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Prepare ou confira a edição antes de decidir. Para retirar, marque a proposta de retirada.'); + } DB::transaction(function () use ($request, $table, $id, $action, $data): void { $relation = DB::table($table)->where('id', $id)->lockForUpdate()->first(); diff --git a/app/Http/Controllers/InvestmentTerritoryController.php b/app/Http/Controllers/InvestmentTerritoryController.php index e098b83e..52137843 100644 --- a/app/Http/Controllers/InvestmentTerritoryController.php +++ b/app/Http/Controllers/InvestmentTerritoryController.php @@ -5,9 +5,12 @@ namespace App\Http\Controllers; use App\Models\DevelopmentCorridor; use App\Models\EconomicCluster; use App\Models\InvestmentDossier; +use App\Models\InvestmentZone; use App\Models\Opportunity; use App\Models\Project; +use App\Services\InvestmentZoneOperationalProfile; use App\Services\InvestmentZones; +use App\Services\PortalEditorialSubjects; use App\Services\PortalEditorialWorkflow; use App\Services\PortalProfileAccess; use App\Services\TerritoryGeometry; @@ -92,6 +95,12 @@ class InvestmentTerritoryController extends Controller $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'); + } elseif (in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->available()) { + $record = app(PortalEditorialSubjects::class)->working($kind, $record); + } elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) { + $working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id)); + $record->primary_territory_id = $working['primary_territory_id']; + $record->territory_version = $working['territory_version']; } $path = []; $current = $record->primary_territory_id; @@ -115,17 +124,26 @@ class InvestmentTerritoryController extends Controller } $data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]); $dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id, true); - $version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind) { + $structural = ! $dossier && in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->writes($kind, $id); + $version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind, $structural) { if ($dossier) { $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); } DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); + if ($structural) { + app(PortalEditorialSubjects::class)->lock($kind, $id, ['primary_territory_id' => $data['path'] === [] ? null : $data['path'][array_key_last($data['path'])]]); + } $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($record !== null, 404); $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'); + } elseif ($structural) { + $effectiveVersion = (int) app(PortalEditorialSubjects::class)->working($kind, $record)->territory_version; + } elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) { + $working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id)); + $effectiveVersion = (int) $working['territory_version']; } abort_unless($effectiveVersion === $data['version'], 409, 'Localização alterada. Recarregue a ficha.'); $parent = null; @@ -145,13 +163,20 @@ class InvestmentTerritoryController extends Controller return $version; } + if ($structural) { + $workflow = app(PortalEditorialWorkflow::class); + $workflow->saveSubject($request->user(), $kind, $id, $workflow->subjectVersion($kind, $id), ['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; $changes['district'] = $names[1] ?? null; } if ($table === 'investment_zones') { - app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id); + $workingVersion = app(InvestmentZoneOperationalProfile::class)->workingVersion(InvestmentZone::findOrFail($id)); + app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $workingVersion], $request->user()->id); } else { DB::table($table)->where('id', $id)->update($changes); } @@ -162,6 +187,8 @@ class InvestmentTerritoryController extends Controller return $version; }); - return response()->json(['version' => $version, 'pending_publication' => $dossier !== null]); + $zoneDraft = $kind === 'zones' && (app(InvestmentZoneOperationalProfile::class)->editorialEnabled() || DB::table('investment_zones')->where('id', $id)->value('category') === 'industrial_park'); + + return response()->json(['version' => $version, 'pending_publication' => $dossier !== null || $structural || $zoneDraft]); } } diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php index c52a4e40..1b6dcea0 100644 --- a/app/Http/Controllers/InvestmentZoneController.php +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -19,6 +19,8 @@ use App\Services\InvestmentZones; use App\Services\PublicSearchMetrics; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Validation\Rule; @@ -291,6 +293,13 @@ class InvestmentZoneController extends Controller return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved)); } + public function editorialReview(Request $request, string $id, string $action): JsonResponse + { + $data = $request->validate(['version_no' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:3000']); + + return response()->json(app(InvestmentZoneOperationalProfile::class)->review($id, $request->user(), (int) $data['version_no'], $action, $data['reason'])); + } + public function revisions(string $id): JsonResponse { InvestmentZone::findOrFail($id); @@ -298,6 +307,30 @@ class InvestmentZoneController extends Controller return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at'])); } + public function revision(Request $request, string $id, int $version): JsonResponse + { + abort_unless(app(InvestmentZoneOperationalProfile::class)->reviewAccess($request->user()), 403); + InvestmentZone::findOrFail($id); + $revision = DB::table('investment_zone_revisions')->where('zone_id', $id)->where('version_no', $version)->first(); + abort_unless($revision !== null, 404); + try { + $json = Crypt::decryptString($revision->snapshot); + abort_unless(hash_equals($revision->sha256, hash('sha256', $json)), 409); + $snapshot = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $fields = $snapshot['draft']['fields'] ?? $snapshot['public'] ?? $snapshot; + $details = $fields['operational_details'] ?? []; + if (isset($details['private_ciphertext'])) { + $details = json_decode(Crypt::decryptString($details['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR); + } + } catch (\Throwable $e) { + abort(409, 'Não foi possível conferir esta versão. O histórico foi preservado.'); + } + + return response()->json(['version_no' => $revision->version_no, 'action' => $revision->action, + 'fields' => Arr::only($fields, ['name', 'description']), + 'observations' => array_map(fn ($row) => Arr::only($row, ['label', 'value', 'unit', 'scope', 'period_start', 'period_end', 'source_reference']), $details['integral']['observations'] ?? [])]); + } + public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse { InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers'); diff --git a/app/Http/Controllers/InvestmentZoneRelationController.php b/app/Http/Controllers/InvestmentZoneRelationController.php index 8550c3c8..78dd0396 100644 --- a/app/Http/Controllers/InvestmentZoneRelationController.php +++ b/app/Http/Controllers/InvestmentZoneRelationController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Models\InvestmentZone; +use App\Services\PortalEditorialSubjects; +use App\Services\PortalEditorialWorkflow; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -36,14 +38,28 @@ class InvestmentZoneRelationController extends Controller DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id, 'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]); $this->audit($relation, $request->user()->id, 'created', 1); + if (app(PortalEditorialSubjects::class)->available()) { + app(PortalEditorialWorkflow::class)->saveSubject($request->user(), 'zone-relations', $relation, 0, [...$data, 'zone_id' => $id], true); + } - return response()->json(DB::table('investment_zone_relations')->where('id', $relation)->first(), 201); + $record = DB::table('investment_zone_relations')->where('id', $relation)->first(); + if (app(PortalEditorialSubjects::class)->available()) { + $record->editorial_url = '/management/editorial/subjects/zone-relations/'.$relation; + $record->pending_publication = true; + } + + return response()->json($record, 201); }); } public function review(Request $request, string $id, string $action): JsonResponse { $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); + if (app(PortalEditorialSubjects::class)->writes('zone-relations', $id)) { + app(PortalEditorialSubjects::class)->authorize($request->user(), 'zone-relations', $id, true); + + return response()->json(['pending_publication' => true, 'editorial_url' => '/management/editorial/subjects/zone-relations/'.$id]); + } return DB::transaction(function () use ($request, $id, $action, $data) { $relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first(); diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php index 56ed620a..d956504e 100644 --- a/app/Http/Controllers/PortalEditorialWorkflowController.php +++ b/app/Http/Controllers/PortalEditorialWorkflowController.php @@ -5,11 +5,15 @@ namespace App\Http\Controllers; use App\Models\User; use App\Services\ContentManagement; use App\Services\DocumentManagementAccess; +use App\Services\PortalEditorialNativeNotices; +use App\Services\PortalEditorialSubjects; use App\Services\PortalEditorialWorkflow; +use App\Services\PortalProfileAccess; use App\Services\TerritorialRegistry; use App\Services\VerifiedDocumentBinary; use App\Support\CheckedFile; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Inertia\Inertia; @@ -17,6 +21,64 @@ use Symfony\Component\HttpFoundation\Response; final class PortalEditorialWorkflowController extends Controller { + public function subjects(Request $request, PortalEditorialWorkflow $workflow): Response + { + $adapter = app(PortalEditorialSubjects::class); + abort_unless($adapter->available() && $request->user()->can('manage-portal') && ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403); + $items = $workflow->subjectQueue()->orderByDesc('e.updated_at')->paginate(25, ['e.id', 'e.subject_kind', 'e.subject_key', 'e.revision_no', 'e.state', DB::raw("e.payload->'resource'->>'title' AS title")]); + + return $this->screen($request, 'management/editorial-subjects', ['items' => $items]); + } + + public function subject(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response + { + $adapter = app(PortalEditorialSubjects::class); + $adapter->authorize($request->user(), $kind, $id); + $record = $adapter->query($kind, $id)->firstOrFail(); + $selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null; + $latest = $workflow->latestSubject($kind, $id); + $editions = DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $id); + $edition = $selected ? (clone $editions)->where('id', $selected)->first() : $latest; + abort_if($selected && ! $edition, 404); + $payload = $edition ? $workflow->payload($edition) : null; + $independent = $edition ? $workflow->requiresIndependent($edition) : $adapter->independent($kind); + $canApprove = ! $independent || ! in_array($request->user()->id, $payload['contributors'] ?? [], true); + + return $this->screen($request, 'management/editorial-edition', ['subject' => 'structural', 'subjectKind' => $kind, 'editUrl' => $adapter->editUrl($kind, $record), 'subjectFields' => $adapter->form($kind), 'subjectInitial' => Arr::only(get_object_vars($record), $adapter->definition($kind)['fields']), + 'resource' => ['id' => $id, 'title' => $record->name ?? $record->title ?? $kind, 'published' => ($record->deleted_at ?? null) === null && ($record->validation_status ?? null) !== 'revoked' && (clone $editions)->where('state', 'published')->exists(), 'public_edition_no' => (clone $editions)->where('state', 'published')->max('revision_no')], + 'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null, + 'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $independent, 'canApprove' => $canApprove, 'manager' => true, 'legalReviewer' => false, + 'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']), + 'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->subject_kind', $kind)->where('after_data->subject_key', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']), + 'editions' => $editions->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]); + } + + public function subjectTransition(Request $request, string $kind, string $id, string $edition, PortalEditorialWorkflow $workflow): Response + { + $result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, false, $kind); + + return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.'); + } + + public function subjectSave(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response + { + $adapter = app(PortalEditorialSubjects::class); + $adapter->authorize($request->user(), $kind, $id, true); + $data = $request->validate(['expected_revision' => 'required|integer|min:1', 'fields' => 'required|array', 'withdraw' => 'sometimes|boolean']); + $edition = $workflow->saveSubject($request->user(), $kind, $id, (int) $data['expected_revision'], $adapter->validate($kind, $data['fields']), false, $data['withdraw'] ?? null); + + return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Proposta privada guardada.'); + } + + public function subjectChoices(Request $request, string $kind, string $id): Response + { + $adapter = app(PortalEditorialSubjects::class); + $adapter->authorize($request->user(), $kind, $id); + $data = $request->validate(['field' => 'required|string|max:80', 'q' => 'nullable|string|max:100']); + + return response()->json(['data' => $adapter->choices($kind, $data['field'], $data['q'] ?? null), 'limit' => 100])->header('Cache-Control', 'private, no-store'); + } + private const KIND = "CASE WHEN e.payload->'resource'->>'legislation' = 'true' OR e.payload->'resource'->>'document_type' = 'legislation' THEN 'legislation' WHEN e.payload->'resource'->>'editorial_content' IS NOT NULL THEN 'article' ELSE 'document' END"; /** @param array $data */ @@ -45,7 +107,9 @@ final class PortalEditorialWorkflowController extends Controller $destinations = app(ContentManagement::class)->access($actor); $data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null, 'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor), - 'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null]; + 'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null, + 'nativePending' => app(PortalEditorialNativeNotices::class)->pending($actor), 'zonesUrl' => app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones') ? '/management/investment/zones' : null, + 'subjectsUrl' => app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') ? '/management/editorial/subjects' : null, 'subjectsPending' => $workflow->subjectPending($actor)]; return $this->screen($request, 'management/editorial-workflow', $data); } @@ -67,7 +131,7 @@ final class PortalEditorialWorkflowController extends Controller 'canApprove' => $independent && app(DocumentManagementAccess::class)->full($request->user()), 'manager' => app(DocumentManagementAccess::class)->full($request->user()), 'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']), - 'legalReviewer' => in_array($request->user()->id, config('editorial_workflow.legal_reviewers', []), true), + 'legalReviewer' => $workflow->legalReviewer($request->user()), 'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->resource_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']), 'editions' => DB::table('portal_editorial_editions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]; diff --git a/app/Http/Controllers/TerritorialRegistryController.php b/app/Http/Controllers/TerritorialRegistryController.php index 9ac523c0..a6ec1dca 100644 --- a/app/Http/Controllers/TerritorialRegistryController.php +++ b/app/Http/Controllers/TerritorialRegistryController.php @@ -19,7 +19,9 @@ class TerritorialRegistryController extends Controller $actor = $request->user(); $roles = array_values(array_filter(['editors', 'reviewers', 'publishers'], fn ($role) => in_array(strtolower($actor->email), config('territorial.'.$role, []), true))); - return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id])->toResponse($request); + $context = $request->validate(['territory' => 'nullable|uuid', 'notice_version' => 'nullable|integer|min:1']); + + return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id, 'initialTerritory' => $context['territory'] ?? null, 'noticeVersion' => $context['notice_version'] ?? null])->toResponse($request); } public function preview(Request $request, TerritorialRegistry $registry, string $id): JsonResponse diff --git a/app/Services/ClusterCompetitiveness.php b/app/Services/ClusterCompetitiveness.php index a02f7a00..0613c1fb 100644 --- a/app/Services/ClusterCompetitiveness.php +++ b/app/Services/ClusterCompetitiveness.php @@ -9,6 +9,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; final class ClusterCompetitiveness @@ -140,13 +141,18 @@ final class ClusterCompetitiveness throw new \LogicException('Unsupported competitiveness action'); } $state['version']++; - DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters', + $eventId = (string) Str::uuid(); + DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::SOURCE, 'entity_type' => 'economic_clusters', 'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor, 'action' => $action, 'data_classification' => 'restricted', 'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR), - 'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null, + 'after_data' => json_encode(['version' => $state['version'], 'editorial_state' => $state['workflow']['state'] ?? null, 'restored_from' => $input['revision'] ?? null, 'reason' => Crypt::encryptString($input['reason'] ?? ''), 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]); + if ($this->editorialEnabled()) { + $user = User::findOrFail($actor); + app(PortalEditorialNativeNotices::class)->emit($user, $eventId, $state['workflow']['contributors']); + } return $this->management($cluster->id); }); diff --git a/app/Services/InvestmentZoneOperationalProfile.php b/app/Services/InvestmentZoneOperationalProfile.php index 05c06281..d08b1ac4 100644 --- a/app/Services/InvestmentZoneOperationalProfile.php +++ b/app/Services/InvestmentZoneOperationalProfile.php @@ -3,11 +3,14 @@ namespace App\Services; use App\Models\InvestmentZone; +use App\Models\User; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpException; /** Editorial preparation in the existing master. No operational/land entitlement is inferred. */ class InvestmentZoneOperationalProfile @@ -16,7 +19,7 @@ class InvestmentZoneOperationalProfile public const FIELDS = ['code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'operator_id', 'legal_source_id', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_components', 'source_evidence', - 'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory']; + 'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory', 'primary_territory_id', 'territory_version']; public const DETAIL_PUBLIC = ['id', 'name', 'status', 'source_reference', 'source_date', 'kind', 'scope', 'capacity_type', 'capacity_value', 'capacity_unit', 'focus', 'economic_activity_id']; @@ -25,6 +28,159 @@ class InvestmentZoneOperationalProfile return Schema::hasColumn('investment_zones', 'editorial_draft'); } + public function editorialEnabled(): bool + { + return (bool) config('editorial_workflow.zone_review') && $this->installed(); + } + + public function ensureWriteMode(InvestmentZone $zone): void + { + if (config('editorial_workflow.zone_review')) { + abort_unless($this->editorialEnabled(), 503, 'A preparação privada das zonas ainda não está instalada.'); + } elseif ($zone->exists) { + abort_if(DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_id', $zone->id)->where('after_data->editorial_workflow', true)->exists(), 503, 'Reactive a revisão das zonas para preservar o histórico.'); + } + } + + public function reviewAccess(User $actor): bool + { + $access = app(PortalProfileAccess::class); + + return $actor->hasVerifiedEmail() && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! $access->has($actor, 'auditor') + && ($actor->can('manage-portal') || $access->superuser($actor) || $access->legacyWriteAllowed($actor)); + } + + private function reviewActor(User $actor): void + { + abort_unless($this->reviewAccess($actor) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Âmbito administrativo e MFA válido necessários.'); + } + + public function authorizeWrite(int $actor): void + { + $this->reviewActor(User::findOrFail($actor)); + } + + /** @param array $draft */ + private function reviewHash(InvestmentZone $zone, array $draft): string + { + $context = ['public_fields' => Arr::only($zone->getRawOriginal(), [...self::FIELDS, 'primary_territory_id', 'territory_version', 'operational_details']), 'locations' => [], 'documents' => []]; + foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations', 'primary_territory_id' => 'geo_locations'] as $field => $table) { + $id = $draft['fields'][$field] ?? $zone->getAttribute($field); + if ($id) { + $row = DB::table($table)->where('id', $id)->first(); + abort_unless($row !== null, 422, 'Fonte ou contexto da zona indisponível.'); + $context[$field] = Arr::except(get_object_vars($row), ['created_at', 'updated_at']); + } + } + $context['locations'] = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->get()->map(fn ($row) => Arr::except(get_object_vars($row), ['created_at', 'updated_at']))->all(); + $geography = []; + $locations = array_filter([$draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id'), ...array_column($context['locations'], 'geo_location_id')]); + foreach (array_unique($locations) as $location) { + $seen = []; + while ($location) { + abort_if(in_array($location, $seen, true) || count($seen) >= 5, 422, 'Hierarquia territorial incoerente.'); + $seen[] = $location; + $node = DB::table('geo_locations')->where('id', $location)->whereNull('deleted_at')->first(); + abort_unless($node !== null, 422, 'Contexto territorial indisponível.'); + $geography[$node->id] = Arr::only(get_object_vars($node), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'source_code']); + $location = $node->parent_id; + } + } + ksort($geography); + $context['geography'] = $geography; + foreach (DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->get() as $attachment) { + $document = DB::table('documents')->where('id', $attachment->document_id)->first(); + abort_unless($document !== null, 422, 'Anexo indisponível.'); + if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) { + app(VerifiedDocumentBinary::class)->respond($document); + } + $context['documents'][] = ['attachment' => Arr::except(get_object_vars($attachment), ['created_at', 'updated_at']), 'document' => Arr::only(get_object_vars($document), ['id', 'sha256', 'version_no', 'status', 'deleted_at', 'rights_status', 'malware_scan_status', 'malware_scanned_sha256'])]; + } + + return hash('sha256', json_encode(Arr::sortRecursive(['fields' => $draft['fields'], 'context' => $context, 'contributors' => $draft['editorial']['contributors'] ?? []]), JSON_THROW_ON_ERROR)); + } + + /** @param array $draft */ + private function freshReview(InvestmentZone $zone, array $draft): bool + { + try { + return (int) $draft['base_public_version'] === (int) $zone->version_no && hash_equals($draft['editorial']['hash'], $this->reviewHash($zone, $draft)); + } catch (HttpException $exception) { + if (in_array($exception->getStatusCode(), [404, 409, 422], true)) { + return false; + } + throw $exception; + } + } + + /** @param array $workflow */ + private function independent(array $workflow): bool + { + return ($workflow['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true); + } + + /** @param array $draft + * @param array|null $previous + * @return array + */ + private function newReview(InvestmentZone $zone, array $draft, int $actor, ?array $previous): array + { + $draft['editorial'] = ['state' => 'draft', 'content_version' => $draft['working_version'], 'contributors' => array_values(array_unique([...($previous['editorial']['contributors'] ?? []), $actor])), + 'independent_required' => ($previous['editorial']['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true), 'approval' => null]; + $draft['editorial']['hash'] = $this->reviewHash($zone, $draft); + + return $draft; + } + + /** @return array */ + public function review(string $id, User $actor, int $expected, string $action, string $reason): array + { + $this->reviewActor($actor); + validator(['reason' => $reason], ['reason' => 'required|string|min:10|max:3000'])->validate(); + abort_unless($this->editorialEnabled(), 503); + + return DB::transaction(function () use ($id, $actor, $expected, $action, $reason) { + $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); + $draft = $this->draft($zone); + abort_unless($expected === $this->workingVersion($zone), 409, 'A preparação mudou. Conserve os campos e confira a versão actual.'); + if (! $draft || ! isset($draft['editorial'])) { + abort_unless($action === 'submit', 409, 'Prepare a revisão antes de decidir.'); + $zone = $this->prepare($zone, ['version_no' => $expected], $actor->id); + $draft = $this->draft($zone); + } + abort_unless($draft !== null && isset($draft['editorial']), 409); + $workflow = $draft['editorial']; + $allowed = ['submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'approve' => ['in_review'], 'return' => ['in_review'], 'reject' => ['in_review'], 'archive' => ['draft', 'submitted', 'in_review', 'approved', 'returned', 'rejected']]; + abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409); + if (in_array($action, ['submit', 'approve'], true)) { + abort_unless($this->freshReview($zone, $draft), 409, 'Conteúdo, fonte ou anexos alterados. Guarde nova edição e volte a conferir.'); + } + if ($action === 'approve') { + abort_if($this->independent($workflow) && in_array($actor->id, $workflow['contributors'], true), 403, 'Esta edição exige aprovação independente.'); + $workflow['approval'] = ['actor_id' => $actor->id, 'content_version' => $workflow['content_version'], 'hash' => $workflow['hash'], 'reason' => $reason, 'at' => now()->toISOString()]; + } + $workflow['state'] = ['submit' => 'submitted', 'start_review' => 'in_review', 'approve' => 'approved', 'return' => 'returned', 'reject' => 'rejected', 'archive' => 'archived'][$action]; + $draft['editorial'] = $workflow; + $draft['working_version'] = $this->nextVersion($zone); + DB::table('investment_zones')->where('id', $id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]); + $this->snapshot($zone, $draft['working_version'], $actor->id, 'zone_editorial_'.$action, ['draft' => $draft, 'reason' => $reason]); + + return $this->management($zone->fresh()); + }); + } + + public function suspensionPreservesReview(InvestmentZone $zone, int $actor): void + { + $draft = $this->draft($zone); + if (! $this->editorialEnabled() || ! $draft) { + return; + } + $draft['base_public_version'] = (int) $zone->version_no; + $draft['working_version'] = $this->nextVersion($zone); + DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]); + $this->snapshot($zone, $draft['working_version'], $actor, 'zone_editorial_suspension_rebased', ['draft' => $draft]); + } + /** * @return array|null */ @@ -60,8 +216,15 @@ class InvestmentZoneOperationalProfile public function management(InvestmentZone $zone): array { $draft = $this->draft($zone); + $workflow = $draft['editorial'] ?? null; + if ($workflow && $draft) { + $workflow['fresh'] = $this->freshReview($zone, $draft); + $workflow['independent_required'] = $this->independent($workflow); + $viewer = auth()->user(); + $workflow['can_approve'] = ! $workflow['independent_required'] || ($viewer instanceof User && ! in_array($viewer->id, $workflow['contributors'], true)); + } - return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no, + return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no, 'editorial_enabled' => $this->editorialEnabled(), 'editorial_workflow' => $workflow, 'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no, 'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(), 'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)), @@ -118,6 +281,10 @@ class InvestmentZoneOperationalProfile */ public function prepare(InvestmentZone $zone, array $data, int $actor): InvestmentZone { + $this->ensureWriteMode($zone); + if ($this->editorialEnabled()) { + $this->reviewActor(User::findOrFail($actor)); + } abort_unless($this->installed(), 503, 'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.'); abort_unless((int) $data['version_no'] === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de guardar.'); $previous = $this->draft($zone); @@ -142,19 +309,38 @@ class InvestmentZoneOperationalProfile } $draft = ['schema_version' => 1, 'base_public_version' => $previous['base_public_version'] ?? (int) $zone->version_no, 'working_version' => $this->nextVersion($zone), 'fields' => $fields, 'actor_id' => $actor, 'prepared_at' => now()->toIso8601String()]; + if ($this->editorialEnabled()) { + $draft = $this->newReview($zone, $draft, $actor, $previous); + } DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]); $this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]); return $zone->fresh(); } - /** Apply only inside the publication transaction; a failed validation rolls everything back. */ - public function promote(InvestmentZone $zone, int $version): void + /** Apply only inside the publication transaction; a failed validation rolls everything back. + * @return array|null + */ + public function promote(InvestmentZone $zone, int $version, ?int $actor = null): ?array { $draft = $this->draft($zone); abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.'); if (! $draft) { - return; + abort_if($this->editorialEnabled(), 409, 'Prepare e aprove a revisão antes de publicar.'); + + return null; + } + if ($this->editorialEnabled()) { + $this->lockReviewContext($zone, $draft); + $publisher = User::where('id', $actor)->firstOrFail(); + $this->reviewActor($publisher); + $review = $draft['editorial'] ?? null; + abort_unless($review && $review['state'] === 'approved' && $this->freshReview($zone, $draft), 409, 'Aprovação desta edição necessária.'); + $approval = $review['approval']; + abort_unless($approval && $approval['content_version'] === $review['content_version'] && hash_equals($approval['hash'], $review['hash']), 409, 'A aprovação não corresponde à edição.'); + $reviewer = User::where('id', $approval['actor_id'])->first(); + abort_unless($reviewer && $this->reviewAccess($reviewer), 403, 'Autoridade da decisão revogada.'); + abort_if($this->independent($review) && (in_array($reviewer->id, $review['contributors'], true) || in_array($publisher->id, $review['contributors'], true)), 403, 'Esta edição exige aprovação/publicação independente.'); } abort_unless((int) $draft['base_public_version'] === (int) $zone->version_no, 409, 'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.'); $zone->fill(array_intersect_key($draft['fields'], array_flip(self::FIELDS))); @@ -166,10 +352,39 @@ class InvestmentZoneOperationalProfile $zone->setAttribute($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]); } $zone->setAttribute('editorial_draft', null); + + return $draft['editorial'] ?? null; + } + + /** @param array $draft */ + private function lockReviewContext(InvestmentZone $zone, array $draft): void + { + foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations'] as $field => $table) { + $id = $draft['fields'][$field] ?? $zone->getAttribute($field); + if ($id) { + DB::table($table)->where('id', $id)->lockForUpdate()->first(); + } + } + $locations = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('geo_location_id')->all(); + $locations[] = $draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id'); + $all = []; + foreach (array_filter($locations) as $location) { + for ($i = 0; $location && $i < 5; $i++) { + $all[] = $location; + $location = DB::table('geo_locations')->where('id', $location)->value('parent_id'); + } + } + DB::table('geo_locations')->whereIn('id', array_unique($all))->orderBy('id')->lockForUpdate()->get(); + $documents = DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('document_id')->all(); + DB::table('documents')->whereIn('id', $documents)->orderBy('id')->lockForUpdate()->get(); } public function reconcile(InvestmentZone $zone, int $working, int $public, int $actor): InvestmentZone { + $this->ensureWriteMode($zone); + if ($this->editorialEnabled()) { + $this->reviewActor(User::findOrFail($actor)); + } $draft = $this->draft($zone); abort_unless($draft && $working === (int) $draft['working_version'] && $public === (int) $zone->version_no, 409, 'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.'); abort_if((int) $draft['base_public_version'] === $public, 422, 'A preparação já corresponde à versão pública atual.'); @@ -177,6 +392,9 @@ class InvestmentZoneOperationalProfile $draft['working_version'] = $this->nextVersion($zone); $draft['actor_id'] = $actor; $draft['prepared_at'] = now()->toIso8601String(); + if ($this->editorialEnabled()) { + $draft = $this->newReview($zone, $draft, $actor, $draft); + } DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]); $this->snapshot($zone, $draft['working_version'], $actor, 'profile_reconciled', ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'draft' => $draft]); @@ -192,7 +410,15 @@ class InvestmentZoneOperationalProfile $hash = hash('sha256', $json); DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version, 'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]); - DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id, - 'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version, 'sha256' => $hash], JSON_THROW_ON_ERROR)]); + $eventId = (string) Str::uuid(); + $metadata = ['version_no' => $version, 'sha256' => $hash]; + if ($this->editorialEnabled()) { + $metadata += ['editorial_workflow' => true, 'editorial_state' => $data['draft']['editorial']['state'] ?? ($action === 'published' ? 'published' : null)]; + } + DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id, + 'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]); + if ($this->editorialEnabled()) { + app(PortalEditorialNativeNotices::class)->emit(User::findOrFail($actor), $eventId, $data['draft']['editorial']['contributors'] ?? $data['decision']['contributors'] ?? []); + } } } diff --git a/app/Services/InvestmentZones.php b/app/Services/InvestmentZones.php index 22625b18..506336c5 100644 --- a/app/Services/InvestmentZones.php +++ b/app/Services/InvestmentZones.php @@ -96,9 +96,14 @@ class InvestmentZones { return DB::transaction(function () use ($id, $data, $actor) { $zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone; - if ($id && $zone->category === 'industrial_park') { - $prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor); + $profile = app(InvestmentZoneOperationalProfile::class); + $profile->ensureWriteMode($zone); + if ($profile->editorialEnabled()) { + $profile->authorizeWrite($actor); + } + if ($id && ($zone->category === 'industrial_park' || $profile->editorialEnabled())) { $this->storePendingLocations($zone, $data['locations'] ?? [], $actor); + $prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor); return $prepared; } @@ -128,8 +133,10 @@ class InvestmentZones return DB::transaction(function () use ($id, $version, $actor) { $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $profile = app(InvestmentZoneOperationalProfile::class); - if ($zone->category === 'industrial_park') { - $profile->promote($zone, $version); + $profile->ensureWriteMode($zone); + $proof = null; + if ($zone->category === 'industrial_park' || $profile->editorialEnabled()) { + $proof = $profile->promote($zone, $version, $actor); } else { abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); } @@ -158,9 +165,9 @@ class InvestmentZones ->where('zl.zone_id', $id)->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')->whereNull('g.deleted_at') ->whereIn('g.id', app(InvestmentGeography::class)->publicReferences()->select('id'))->exists(); abort_unless($publicLocation, 422, 'Seleccione uma província ou distrito existente e público.'); - $zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published', + $zone->fill(['version_no' => $zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->nextVersion($zone) : $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published', 'validation_status' => 'validated', 'published_at' => now()])->save(); - $this->snapshot($zone, $actor, 'published'); + $this->snapshot($zone, $actor, 'published', $proof); return $zone; }); @@ -171,11 +178,16 @@ class InvestmentZones return DB::transaction(function () use ($id, $version, $actor) { $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $profile = app(InvestmentZoneOperationalProfile::class); - abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.'); - $zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save(); + $profile->ensureWriteMode($zone); + if ($profile->editorialEnabled()) { + $profile->authorizeWrite($actor); + } + abort_if(($zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.'); + $zone->fill(['version_no' => $zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save(); $this->snapshot($zone, $actor, 'suspended'); + $profile->suspensionPreservesReview($zone, $actor); - return $zone; + return $profile->editorialEnabled() ? $zone->fresh() : $zone; }); } @@ -203,8 +215,14 @@ class InvestmentZones }); } - private function snapshot(InvestmentZone $zone, int $actor, string $action): void + /** @param array|null $decision */ + private function snapshot(InvestmentZone $zone, int $actor, string $action, ?array $decision = null): void { + if (app(InvestmentZoneOperationalProfile::class)->editorialEnabled()) { + app(InvestmentZoneOperationalProfile::class)->snapshot($zone, (int) $zone->version_no, $actor, $action, ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'decision' => $decision]); + + return; + } $json = json_encode([...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], JSON_THROW_ON_ERROR); DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action, diff --git a/app/Services/PortalEditorialNativeNotices.php b/app/Services/PortalEditorialNativeNotices.php new file mode 100644 index 00000000..fc114b87 --- /dev/null +++ b/app/Services/PortalEditorialNativeNotices.php @@ -0,0 +1,146 @@ +available() && Schema::hasColumn('portal_editorial_notifications', 'native_event_id'); + } + + public function access(User $actor, string $source): bool + { + if (! $actor->hasVerifiedEmail() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret + || app(PortalProfileAccess::class)->has($actor, 'auditor')) { + return false; + } + if ($source === 'investment-zones') { + return app(InvestmentZoneOperationalProfile::class)->editorialEnabled() && app(InvestmentZoneOperationalProfile::class)->reviewAccess($actor); + } + if (! $actor->can('manage-portal')) { + return false; + } + + return match ($source) { + 'cluster-competitiveness' => app(ClusterCompetitiveness::class)->editorialEnabled(), + 'territorial-editorial-v1' => app(TerritorialRegistry::class)->editorialAvailable(), + default => false, + }; + } + + /** @return array{clusters:int,territories:int,zones:int} */ + public function pending(User $actor): array + { + $counts = ['clusters' => 0, 'territories' => 0, 'zones' => 0]; + if (! $this->available()) { + return $counts; + } + if ($this->access($actor, 'cluster-competitiveness')) { + $events = DB::table('audit_events as a')->join('economic_clusters as c', 'c.id', '=', 'a.entity_id') + ->where('a.source_system', 'cluster-competitiveness')->whereNull('c.deleted_at') + ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('audit_events as newer')->whereColumn('newer.entity_id', 'a.entity_id') + ->where('newer.source_system', 'cluster-competitiveness')->whereRaw("(newer.after_data->>'version')::integer > (a.after_data->>'version')::integer")) + ->select(['a.id', 'a.after_data'])->orderBy('a.id')->cursor(); + foreach ($events as $event) { + $payload = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); + $stage = $payload['editorial_state'] ?? null; + if ($stage === null) { + $state = json_decode(Crypt::decryptString($payload['snapshot']), true, 512, JSON_THROW_ON_ERROR); + $stage = $state['workflow']['state'] ?? null; + } + if ($stage !== null && ! in_array($stage, ['published', 'archived'], true)) { + $counts['clusters']++; + } + } + } + if ($this->access($actor, 'territorial-editorial-v1')) { + $latest = DB::table('audit_events')->where('source_system', 'territorial-editorial-v1') + ->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'state' AS editorial_state") + ->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC"); + $counts['territories'] = DB::table('geo_territory_profiles as p')->join('geo_locations as g', 'g.id', '=', 'p.geo_location_id') + ->leftJoinSub($latest, 'a', 'a.entity_id', '=', 'g.id')->whereNull('g.deleted_at') + ->whereNotIn(DB::raw('COALESCE(a.editorial_state,p.status)'), ['published', 'archived'])->count(); + } + + if ($this->access($actor, 'investment-zones')) { + $latest = DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_type', 'investment_zones')->where('after_data->editorial_workflow', true) + ->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'editorial_state' AS editorial_state") + ->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC"); + $counts['zones'] = DB::query()->fromSub($latest, 'a')->join('investment_zones as z', 'z.id', '=', 'a.entity_id')->whereNull('z.deleted_at') + ->whereIn('a.editorial_state', ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved'])->count(); + } + + return $counts; + } + + /** @param list $participants */ + public function emit(User $actor, string $eventId, array $participants): void + { + if (! $this->available()) { + return; + } + $event = DB::table('audit_events')->where('id', $eventId)->first(); + abort_unless($event !== null, 409); + if (in_array($event->action, ['saved', 'restored', 'draft_saved', 'profile_prepared', 'profile_reconciled', 'zone_editorial_suspension_rebased'], true)) { + return; + } + $emails = $event->source_system === 'territorial-editorial-v1' + ? [...config('territorial.editors', []), ...config('territorial.reviewers', []), ...config('territorial.publishers', [])] + : config('portal.managers', []); + $recipients = User::query()->where(fn ($q) => $q->whereIn('id', $participants)->orWhereIn(DB::raw('lower(email)'), array_map('strtolower', $emails)))->get(); + foreach ($recipients as $recipient) { + if ($recipient->id === $actor->id || ! $this->access($recipient, $event->source_system)) { + continue; + } + DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient->id, 'native_event_id' => $eventId, + 'edition_id' => null, 'opportunity_id' => null, 'action' => $event->action, 'content_hash' => $this->eventHash($event), 'created_at' => now()]); + } + } + + public function visible(User $actor, \stdClass $notice): bool + { + if (! $this->available() || (int) $notice->user_id !== $actor->id) { + return false; + } + $event = DB::table('audit_events')->where('id', $notice->native_event_id)->first(); + if (! $event || ! $this->access($actor, $event->source_system) || ! is_string($notice->content_hash) + || ! hash_equals(trim($notice->content_hash), $this->eventHash($event))) { + return false; + } + $table = match ($event->source_system) { + 'cluster-competitiveness' => 'economic_clusters', 'investment-zones' => 'investment_zones', default => 'geo_locations' + }; + + return DB::table($table)->where('id', $event->entity_id)->whereNull('deleted_at')->exists(); + } + + private function eventHash(\stdClass $event): string + { + return hash('sha256', json_encode([$event->id, $event->source_system, $event->entity_type, $event->entity_id, $event->action, $event->after_data], JSON_THROW_ON_ERROR)); + } + + /** @return array */ + public function present(\stdClass $notice): array + { + $event = DB::table('audit_events')->where('id', $notice->native_event_id)->first(); + abort_unless($event !== null, 404); + $data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); + $cluster = $event->source_system === 'cluster-competitiveness'; + $version = $data[$cluster ? 'version' : 'version_no']; + if ($event->source_system === 'investment-zones') { + return ['id' => $notice->id, 'action' => str_replace('zone_editorial_', '', $notice->action), 'read_at' => $notice->read_at, + 'href' => '/management/investment/zones/'.$event->entity_id.'?notice_version='.$version, 'reference' => 'Zona e indicadores — versão '.$version]; + } + + return ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at, + 'href' => $cluster ? '/management/clusters/'.$event->entity_id.'/edit?notice_version='.$version : '/management/territorial/editorial?territory='.$event->entity_id.'¬ice_version='.$version, + 'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version]; + } +} diff --git a/app/Services/PortalEditorialSubjects.php b/app/Services/PortalEditorialSubjects.php new file mode 100644 index 00000000..17799f39 --- /dev/null +++ b/app/Services/PortalEditorialSubjects.php @@ -0,0 +1,394 @@ + ['table' => 'projects', 'fields' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version']], + 'clusters' => ['table' => 'economic_clusters', 'fields' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version']], + 'corridors' => ['table' => 'development_corridors', 'fields' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version']], + 'cluster-project' => ['table' => 'cluster_projects', 'fields' => ['cluster_id', 'project_id', 'role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']], + 'corridor-project' => ['table' => 'corridor_projects', 'fields' => ['corridor_id', 'project_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']], + 'corridor-cluster' => ['table' => 'corridor_clusters', 'fields' => ['corridor_id', 'cluster_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']], + 'value-chains' => ['table' => 'value_chains', 'fields' => ['cluster_id', 'code', 'name', 'stage']], + 'value-chain-links' => ['table' => 'value_chain_links', 'fields' => ['from_value_chain_id', 'to_value_chain_id', 'link_type']], + 'investment-gaps' => ['table' => 'investment_gaps', 'fields' => ['cluster_id', 'value_chain_id', 'title', 'location', 'required_capacity', 'rationale', 'status', 'call_to_action']], + 'zone-relations' => ['table' => 'investment_zone_relations', 'fields' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id', 'association_type', 'role', 'source_reference', 'justification', 'legal_source_id', 'valid_from', 'valid_until', 'priority', 'is_primary']], + ]; + + private const PARENTS = ['cluster_id' => 'economic_clusters', 'project_id' => 'projects', 'corridor_id' => 'development_corridors', 'zone_id' => 'investment_zones', + 'value_chain_id' => 'value_chains', 'from_value_chain_id' => 'value_chains', 'to_value_chain_id' => 'value_chains', 'primary_territory_id' => 'geo_locations']; + + /** @return array{table:string,fields:list} */ + public function definition(string $kind): array + { + abort_unless(isset(self::SUBJECTS[$kind]), 404); + + return self::SUBJECTS[$kind]; + } + + public function available(): bool + { + return (bool) config('editorial_workflow.structural_subjects') && app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_editions', 'subject_kind'); + } + + public function writes(string $kind, string $key): bool + { + $this->definition($kind); + if (config('editorial_workflow.structural_subjects')) { + abort_unless($this->available(), 503, 'Estrutura editorial dos registos ainda não instalada.'); + + return true; + } + abort_if(Schema::hasColumn('portal_editorial_editions', 'subject_kind') && DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->exists(), 503, 'Reactive o circuito para conservar as versões deste registo.'); + + return false; + } + + public function query(string $kind, string $key): Builder + { + return DB::table($this->definition($kind)['table'])->where('id', $key); + } + + public function editUrl(string $kind, \stdClass $record): string + { + return match ($kind) { + 'projects', 'clusters', 'corridors' => '/management/'.$kind.'/'.$record->id.'/edit', + 'value-chains', 'investment-gaps', 'cluster-project' => '/management/clusters/'.$record->cluster_id.'/edit', + 'value-chain-links' => '/management/clusters/'.DB::table('value_chains')->where('id', $record->from_value_chain_id)->value('cluster_id').'/edit', + 'corridor-project', 'corridor-cluster' => '/management/corridors/'.$record->corridor_id.'/edit', + 'zone-relations' => '/management/investment/zones/'.$record->zone_id, + default => abort(404), + }; + } + + public function working(string $kind, \stdClass $record): \stdClass + { + if (! $this->available()) { + return $record; + } + $workflow = app(PortalEditorialWorkflow::class); + $edition = $workflow->latestSubject($kind, $record->id); + $copy = clone $record; + if ($edition && $edition->published_at === null && $edition->state !== 'archived') { + foreach ($workflow->payload($edition)['resource']['_subject']['fields'] as $field => $value) { + $copy->{$field} = $value; + } + } + $copy->editorial_version = (int) ($edition->revision_no ?? 1); + $copy->editorial_url = '/management/editorial/subjects/'.$kind.'/'.$record->id; + + return $copy; + } + + /** @param array $fields */ + public function stageModel(User $actor, string $kind, Model $record, array $fields, int $expected): \stdClass + { + return DB::transaction(function () use ($actor, $kind, $record, $fields, $expected) { + $new = ! $record->exists; + if ($new) { + $private = in_array($kind, ['value-chains', 'value-chain-links', 'investment-gaps'], true) ? ['deleted_at' => now()] : []; + PortalAudit::save($record, [...$fields, ...$private], $actor->id); + } + $allowed = $this->definition($kind)['fields']; + + return app(PortalEditorialWorkflow::class)->saveSubject($actor, $kind, (string) $record->getKey(), $expected, Arr::only($fields, $allowed), $new); + }); + } + + /** @return array */ + public function labels(): array + { + return ['code' => 'Código', 'name' => 'Designação', 'status' => 'Estado', 'description' => 'Descrição', 'sector' => 'Sector', 'budget_amount' => 'Orçamento', 'budget_currency' => 'Moeda', 'start_date' => 'Data de início', 'end_date' => 'Data de conclusão', + 'primary_territory_id' => 'Localização principal', 'cluster_id' => 'Cluster', 'project_id' => 'Projecto', 'corridor_id' => 'Corredor', 'zone_id' => 'Zona', 'value_chain_id' => 'Etapa da cadeia', 'from_value_chain_id' => 'Etapa de origem', 'to_value_chain_id' => 'Etapa de destino', + 'role' => 'Papel', 'association_type' => 'Tipo de associação', 'source_reference' => 'Fonte', 'evidence_reference' => 'Evidência', 'valid_from' => 'Válido desde', 'valid_until' => 'Válido até', 'stage' => 'Fase da cadeia', 'link_type' => 'Tipo de ligação', + 'title' => 'Título', 'location' => 'Localização', 'required_capacity' => 'Capacidade necessária', 'rationale' => 'Fundamento', 'call_to_action' => 'Acção proposta', 'justification' => 'Justificação', 'legal_source_id' => 'Fonte jurídica', 'priority' => 'Prioridade', 'is_primary' => 'Relação principal']; + } + + /** @return array> */ + private function enums(string $kind): array + { + return ['status' => match ($kind) { + 'projects' => ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'], 'clusters' => ['planned', 'active', 'inactive'], 'corridors' => ['proposed', 'active', 'closed'], 'investment-gaps' => ['identified', 'in_review', 'resolved', 'withdrawn'], default => [] + }, + 'stage' => ['input', 'production', 'processing', 'logistics', 'market'], 'link_type' => ['supplies', 'processes', 'distributes', 'supports'], + 'association_type' => ['official', 'influence', 'proximity', 'potential', 'historical'], 'role' => $kind === 'zone-relations' ? ['primary', 'beneficiary', 'served', 'enabled', 'dependent', 'related', 'potential', 'historical'] : ['anchor', 'complementary', 'supporting']]; + } + + /** @return list> */ + public function form(string $kind): array + { + $labels = $this->labels(); + $enums = $this->enums($kind); + $result = []; + foreach ($this->definition($kind)['fields'] as $field) { + if ($field === 'territory_version') { + continue; + } + $type = isset(self::PARENTS[$field]) || $field === 'legal_source_id' ? 'reference' : (isset($enums[$field]) ? 'enum' : (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true) ? 'date' : ($field === 'is_primary' ? 'boolean' : 'text'))); + $result[] = ['name' => $field, 'label' => $labels[$field] ?? $field, 'type' => $type, 'options' => $enums[$field] ?? []]; + } + + return $result; + } + + /** @param array $input + * @return array + */ + public function validate(string $kind, array $input): array + { + $allowed = array_values(array_diff($this->definition($kind)['fields'], ['territory_version'])); + abort_unless(array_diff(array_keys($input), $allowed) === [], 422, 'Campos fora do âmbito da proposta.'); + $enums = $this->enums($kind); + $rules = []; + foreach ($allowed as $field) { + $rules[$field] = ['sometimes', 'nullable', 'string', 'max:10000']; + if (isset(self::PARENTS[$field]) || $field === 'legal_source_id') { + $table = self::PARENTS[$field] ?? 'investment_legal_sources'; + $rules[$field] = ['sometimes', 'nullable', 'uuid', 'exists:'.$table.',id']; + } elseif (isset($enums[$field])) { + $rules[$field] = ['sometimes', 'required', Rule::in($enums[$field])]; + } elseif (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true)) { + $rules[$field] = ['sometimes', 'nullable', 'date_format:Y-m-d']; + } + } + foreach (['name', 'code', 'title', 'sector'] as $field) { + if (in_array($field, $allowed, true)) { + $rules[$field] = ['sometimes', 'required', 'string', 'max:'.($field === 'code' ? '100' : '200')]; + } + } + foreach (['source_reference' => 2000, 'evidence_reference' => 2000, 'justification' => 3000, 'location' => 1000, 'required_capacity' => 2000, 'rationale' => 5000, 'call_to_action' => 2000] as $field => $limit) { + if (in_array($field, $allowed, true)) { + $rules[$field] = ['sometimes', 'nullable', 'string', 'max:'.$limit]; + } + } + $requiredReferences = match ($kind) { + 'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'], + 'value-chains' => ['cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id'], 'investment-gaps' => ['cluster_id', 'value_chain_id'], 'zone-relations' => ['zone_id'], default => [], + }; + foreach ($requiredReferences as $field) { + $rules[$field] = ['sometimes', 'required', 'uuid', 'exists:'.self::PARENTS[$field].',id']; + } + if (! empty($input['valid_until']) && ! empty($input['valid_from'])) { + $rules['valid_until'][] = 'after_or_equal:valid_from'; + } + if (! empty($input['end_date']) && ! empty($input['start_date'])) { + $rules['end_date'][] = 'after_or_equal:start_date'; + } + if (in_array('budget_amount', $allowed, true)) { + $rules['budget_amount'] = ['sometimes', 'nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/']; + $rules['budget_currency'] = ['sometimes', 'nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/']; + } + if (in_array('priority', $allowed, true)) { + $rules['priority'] = ['sometimes', 'integer', 'between:0,100']; + $rules['is_primary'] = ['sometimes', 'boolean']; + } + if ($kind === 'zone-relations' && count(array_filter(Arr::only($input, ['cluster_id', 'corridor_id', 'project_id']))) > 1) { + throw ValidationException::withMessages(['project_id' => 'Seleccione apenas um projecto, cluster ou corredor para a relação.']); + } + + return validator($input, $rules)->validate(); + } + + /** @return list */ + public function choices(string $kind, string $field, ?string $query = null): array + { + abort_unless(in_array($field, $this->definition($kind)['fields'], true), 404); + $table = self::PARENTS[$field] ?? ($field === 'legal_source_id' ? 'investment_legal_sources' : abort(404)); + $label = $table === 'investment_legal_sources' ? 'title' : 'name'; + $builder = DB::table($table); + if ($table === 'geo_locations') { + $builder->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])->whereIn('data_classification', ['public', 'internal']); + } + if (Schema::hasColumn($table, 'deleted_at')) { + $builder->whereNull('deleted_at'); + } + if ($query) { + $builder->where($label, 'ilike', '%'.addcslashes($query, '%_\\').'%'); + } + + return array_values($builder->orderBy($label)->orderBy('id')->limit(100)->get(['id', $label.' as name'])->map(fn ($row) => ['id' => (string) $row->id, 'name' => (string) $row->name])->all()); + } + + public function authorize(User $actor, string $kind, string $key, bool $write = false): void + { + abort_unless($this->available(), 503); + abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); + abort_unless($this->query($kind, $key)->exists(), 404); + if ($write) { + abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); + } + } + + public function independent(string $kind): bool + { + return in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true) + || in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true); + } + + /** @param array $fields + * @return array + */ + public function snapshot(string $kind, string $key, array $fields): array + { + $definition = $this->definition($kind); + $record = $this->query($kind, $key)->first(); + abort_unless($record !== null, 404); + abort_unless(array_diff(array_keys($fields), $definition['fields']) === [], 422, 'Campos fora do âmbito do registo.'); + $context = ['record' => Arr::except(get_object_vars($record), ['created_at', 'updated_at']), 'parents' => []]; + if (! empty($fields['primary_territory_id'])) { + $path = app(InvestmentGeography::class)->path($fields['primary_territory_id'], true); + abort_unless($path !== [] && count($path) <= 4 && $path[0]->location_type === 'province', 422, 'Seleccione uma localização na hierarquia existente.'); + } + foreach (self::PARENTS as $field => $table) { + $id = $fields[$field] ?? $record->{$field} ?? null; + if (! $id) { + continue; + } + $parent = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first(); + abort_unless($parent !== null, 422, 'Contexto relacionado indisponível.'); + $context['parents'][$field] = Arr::except(get_object_vars($parent), ['created_at', 'updated_at']); + } + if (in_array($kind, ['projects', 'clusters', 'corridors'], true)) { + $foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind]; + $context['portfolio'] = DB::table('investment_dossiers')->where($foreign, $key)->pluck('id')->all(); + abort_unless($context['portfolio'] === [], 409, 'Este registo tem ficha associada. Continue na revisão da carteira.'); + } + if ($source = $fields['legal_source_id'] ?? $record->legal_source_id ?? null) { + $sourceRecord = DB::table('investment_legal_sources')->where('id', $source)->first(); + abort_unless($sourceRecord !== null, 422, 'Fonte jurídica indisponível.'); + $context['legal_source'] = get_object_vars($sourceRecord); + } + + return ['fields' => $fields, 'context' => $context]; + } + + /** @param array $fields */ + public function lock(string $kind, string $key, array $fields = []): \stdClass + { + $record = $this->query($kind, $key)->first(); + abort_unless($record !== null, 404); + $parents = []; + foreach (self::PARENTS as $field => $table) { + $id = $fields[$field] ?? $record->{$field} ?? null; + if ($id) { + $parents[$table][] = $id; + } + } + ksort($parents); + foreach ($parents as $table => $ids) { + DB::table($table)->whereIn('id', array_unique($ids))->orderBy('id')->lockForUpdate()->get(); + } + + return $this->query($kind, $key)->lockForUpdate()->firstOrFail(); + } + + /** @param array $payload */ + public function publish(User $actor, \stdClass $record, \stdClass $edition, array $payload): void + { + $subject = $payload['resource']['_subject']; + $kind = $edition->subject_kind; + $fields = $subject['fields']; + $this->lock($kind, $edition->subject_key, $fields); + if ($subject['withdraw'] ?? false) { + $this->archive($actor, $record, $edition); + + return; + } + $pair = match ($kind) { + 'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'], + 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id', 'link_type'], + 'zone-relations' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id'], default => [], + }; + if ($pair !== []) { + abort_if($kind === 'zone-relations' && count(array_filter(Arr::only($fields, ['cluster_id', 'corridor_id', 'project_id']))) !== 1, 422, 'A relação exige um projecto, cluster ou corredor.'); + $duplicates = DB::table($this->definition($kind)['table'])->where('id', '<>', $record->id); + foreach ($pair as $field) { + $duplicates->where($field, $fields[$field] ?? null); + } + if (property_exists($record, 'deleted_at')) { + $duplicates->whereNull('deleted_at'); + } + abort_if($duplicates->exists(), 409, 'Esta relação já existe. Reveja a proposta sem duplicar o registo.'); + } + if (in_array($kind, ['projects', 'clusters', 'corridors', 'value-chains'], true) && isset($fields['code'])) { + $query = DB::table($this->definition($kind)['table'])->where('code', $fields['code'])->where('id', '<>', $record->id)->whereNull('deleted_at'); + if ($kind === 'value-chains') { + $query->where('cluster_id', $fields['cluster_id'] ?? $record->cluster_id); + } + abort_if($query->exists(), 409, 'Código entretanto utilizado. Reveja a proposta.'); + } + if (in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)) { + foreach (['source_reference', 'valid_from'] as $required) { + abort_unless(! empty($fields[$required]), 422, 'Fonte e vigência são necessárias.'); + } + abort_if($kind === 'zone-relations' && ($fields['association_type'] ?? '') === 'official' && empty($fields['legal_source_id']), 422, 'A associação oficial exige a fonte jurídica prevista.'); + if (! empty($fields['legal_source_id'])) { + $source = DB::table('investment_legal_sources')->where('id', $fields['legal_source_id'])->lockForUpdate()->first(); + abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString() + && (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica fora de vigência.'); + } + $decision = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR); + $fields += ['validation_status' => 'validated', 'validator_id' => $decision['actor_id'], 'editor_id' => $edition->author_id]; + if (property_exists($record, 'data_classification')) { + $fields['data_classification'] = 'public'; + } + } + if ($kind === 'value-chain-links') { + $chains = DB::table('value_chains')->whereIn('id', [$fields['from_value_chain_id'], $fields['to_value_chain_id']])->whereNull('deleted_at')->get(['id', 'cluster_id']); + abort_unless($chains->count() === 2 && $chains->pluck('cluster_id')->unique()->count() === 1, 422, 'As etapas devem ser distintas e pertencer ao mesmo cluster.'); + } + if ($kind === 'investment-gaps') { + abort_unless(DB::table('value_chains')->where('id', $fields['value_chain_id'])->where('cluster_id', $fields['cluster_id'])->whereNull('deleted_at')->exists(), 422, 'Etapa fora do cluster da lacuna.'); + } + if ($subject['new_record'] ?? false) { + $fields['deleted_at'] = null; + } + if (property_exists($record, 'data_classification')) { + $fields['data_classification'] = 'public'; + } + if (property_exists($record, 'version_no')) { + $fields['version_no'] = (int) $record->version_no + 1; + } + $fields['updated_at'] = now(); + $this->query($kind, $record->id)->update($fields); + $this->audit($actor, $edition, 'published', $record); + } + + public function archive(User $actor, \stdClass $record, \stdClass $edition): void + { + $fields = property_exists($record, 'validation_status') ? ['validation_status' => 'revoked'] + : (in_array($edition->subject_kind, ['projects', 'clusters', 'corridors'], true) ? ['data_classification' => 'internal'] : ['deleted_at' => now()]); + if (property_exists($record, 'version_no')) { + $fields['version_no'] = (int) $record->version_no + 1; + } + $this->query($edition->subject_kind, $record->id)->update([...$fields, 'updated_at' => now()]); + $this->audit($actor, $edition, 'archived', $record); + } + + private function audit(User $actor, \stdClass $edition, string $action, \stdClass $before): void + { + $after = $this->query($edition->subject_kind, $edition->subject_key)->firstOrFail(); + $changed = []; + foreach (get_object_vars($after) as $field => $value) { + if (($before->{$field} ?? null) !== $value) { + $changed[] = $field; + } + } + DB::table('audit_events')->insert(['source_system' => 'editorial-structural-subjects', 'entity_type' => $this->definition($edition->subject_kind)['table'], + 'entity_id' => $edition->subject_key, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', + 'before_data' => json_encode(Arr::only(get_object_vars($before), ['id', 'version_no', 'validation_status', 'deleted_at', 'data_classification']), JSON_THROW_ON_ERROR), + 'after_data' => json_encode(['edition_id' => $edition->id, 'hash' => $edition->content_hash, 'record_id' => $edition->subject_key, 'changed_fields' => $changed], JSON_THROW_ON_ERROR)]); + } +} diff --git a/app/Services/PortalEditorialWorkflow.php b/app/Services/PortalEditorialWorkflow.php index 2469ace1..9b3e6898 100644 --- a/app/Services/PortalEditorialWorkflow.php +++ b/app/Services/PortalEditorialWorkflow.php @@ -24,6 +24,93 @@ use Symfony\Component\HttpKernel\Exception\HttpException; */ final class PortalEditorialWorkflow { + public function legalReviewer(User $actor): bool + { + return $actor->hasVerifiedEmail() && (in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true) + || in_array(strtolower($actor->email), config('editorial_workflow.legal_reviewer_emails', []), true)); + } + + public function latestSubject(string $kind, string $key): ?\stdClass + { + return DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->orderByDesc('revision_no')->first(); + } + + public function subjectVersion(string $kind, string $key): int + { + return (int) ($this->latestSubject($kind, $key)->revision_no ?? 1); + } + + /** @param array $fields + * @param list $contributors + * @return EditorialPayload + */ + private function subjectSnapshot(string $kind, string $key, array $fields, array $contributors, bool $newRecord, bool $independent = false, bool $withdraw = false): array + { + $adapter = app(PortalEditorialSubjects::class); + $snapshot = $adapter->snapshot($kind, $key, $fields); + + return ['resource' => ['title' => (string) ($fields['name'] ?? $fields['title'] ?? $kind), '_subject' => ['kind' => $kind, 'key' => $key, 'fields' => $fields, 'new_record' => $newRecord, 'withdraw' => $withdraw]], + 'links' => [['target_type' => $adapter->definition($kind)['table'], 'target_key' => $key, 'context' => $snapshot['context']]], + 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => $independent || $adapter->independent($kind)]]; + } + + /** @param array $changes */ + public function saveSubject(User $actor, string $kind, string $key, int $expected, array $changes, bool $newRecord = false, ?bool $withdraw = null): \stdClass + { + $adapter = app(PortalEditorialSubjects::class); + $adapter->authorize($actor, $kind, $key, true); + + return DB::transaction(function () use ($actor, $kind, $key, $expected, $changes, $newRecord, $adapter, $withdraw) { + $record = $adapter->lock($kind, $key, $changes); + $previous = $this->latestSubject($kind, $key); + abort_unless($expected === (int) ($previous->revision_no ?? ($newRecord ? 0 : 1)), 409, 'A proposta foi alterada. Conserve os campos e confira a versão actual.'); + $allowed = $adapter->definition($kind)['fields']; + abort_unless(array_diff(array_keys($changes), $allowed) === [], 422, 'Campos fora do âmbito do registo.'); + if (! $previous && ! $newRecord) { + $baseline = $this->subjectSnapshot($kind, $key, Arr::only(get_object_vars($record), $allowed), [], false); + $public = ($record->data_classification ?? null) === 'public' || ($record->validation_status ?? null) === 'validated'; + DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'subject_kind' => $kind, 'subject_key' => $key, 'revision_no' => 1, 'version' => 1, + 'state' => $public ? 'published' : 'archived', 'payload' => json_encode($baseline, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($baseline), + 'author_id' => $actor->id, 'legal_required' => false, 'published_at' => $public ? now() : null, 'created_at' => now(), 'updated_at' => now()]); + } + $working = $previous && $previous->published_at === null && $previous->state !== 'archived' ? $this->payload($previous) : null; + $fields = array_replace(Arr::only(get_object_vars($record), $allowed), $working['resource']['_subject']['fields'] ?? [], $changes); + $adapter->validate($kind, Arr::except($fields, ['territory_version'])); + if (array_key_exists('primary_territory_id', $changes) && ! array_key_exists('territory_version', $changes)) { + $prior = $working['resource']['_subject']['fields'] ?? get_object_vars($record); + if (($prior['primary_territory_id'] ?? null) !== $changes['primary_territory_id']) { + $fields['territory_version'] = (int) ($prior['territory_version'] ?? 0) + 1; + } + } + $contributors = array_values(array_unique([...($working['contributors'] ?? []), $actor->id])); + $snapshot = $this->subjectSnapshot($kind, $key, $fields, $contributors, $newRecord || ($working['resource']['_subject']['new_record'] ?? false), $previous ? $this->requiresIndependent($previous) : false, $withdraw ?? ($working['resource']['_subject']['withdraw'] ?? false)); + $id = (string) Str::uuid(); + DB::table('portal_editorial_editions')->insert(['id' => $id, 'subject_kind' => $kind, 'subject_key' => $key, 'revision_no' => $expected + 1, 'version' => 1, + 'state' => 'draft', 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot), 'author_id' => $actor->id, + 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]); + $edition = DB::table('portal_editorial_editions')->where('id', $id)->firstOrFail(); + $this->audit($actor, $edition, 'draft_saved', 'Proposta privada guardada; registo anterior preservado.'); + + return $edition; + }); + } + + public function subjectPending(User $actor): int + { + if (! app(PortalEditorialSubjects::class)->available() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) { + return 0; + } + + return $this->subjectQueue()->whereNotIn('e.state', ['published', 'archived'])->count(); + } + + public function subjectQueue(): Builder + { + return DB::table('portal_editorial_editions as e')->whereNotNull('e.subject_kind') + ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.subject_kind', 'e.subject_kind') + ->whereColumn('later.subject_key', 'e.subject_key')->whereColumn('later.revision_no', '>', 'e.revision_no')); + } + 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'], @@ -122,6 +209,12 @@ final class PortalEditorialWorkflow if ($access->full($actor) || $access->editorial($actor)) { return true; } + if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal')) { + return true; + } + if (app(PortalEditorialNativeNotices::class)->access($actor, 'cluster-competitiveness') || app(PortalEditorialNativeNotices::class)->access($actor, 'territorial-editorial-v1') || app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones')) { + return true; + } $navigation = app(PortalProfileAccess::class)->navigation($actor); return ! empty($navigation['mine']) || ! empty($navigation['review']); @@ -158,6 +251,9 @@ final class PortalEditorialWorkflow if ((int) $notice->user_id !== $actor->id || ! $this->available() || ! $this->canEnter($actor)) { return false; } + if ($notice->native_event_id ?? null) { + return app(PortalEditorialNativeNotices::class)->visible($actor, $notice); + } if ($notice->opportunity_id !== null) { $record = Opportunity::query()->whereKey($notice->opportunity_id)->first(); @@ -168,7 +264,12 @@ final class PortalEditorialWorkflow return false; } try { - if ($edition->demographic_import_id ?? null) { + if ($edition->subject_kind ?? null) { + if (! app(PortalEditorialSubjects::class)->available()) { + return false; + } + app(PortalEditorialSubjects::class)->authorize($actor, $edition->subject_kind, $edition->subject_key); + } elseif ($edition->demographic_import_id ?? null) { if (! $this->demographyAvailable() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret) { return false; } @@ -201,6 +302,9 @@ final class PortalEditorialWorkflow public function requiresIndependent(\stdClass $edition): bool { $p = $this->payload($edition); + if ($edition->subject_kind ?? null) { + return $p['policy']['independent_required'] || app(PortalEditorialSubjects::class)->independent($edition->subject_kind); + } if ($edition->demographic_import_id ?? null) { return $p['policy']['independent_required'] || in_array('demography', config('editorial_workflow.independent_approval.kinds', []), true); } @@ -220,9 +324,18 @@ final class PortalEditorialWorkflow if (! $this->visibleNotice($actor, $notice)) { continue; } + if ($notice->native_event_id ?? null) { + $result[] = app(PortalEditorialNativeNotices::class)->present($notice); + if (count($result) === 50) { + break; + } + + continue; + } $edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null; + $subjectHref = ($edition->subject_kind ?? null) ? '/management/editorial/subjects/'.$edition->subject_kind.'/'.$edition->subject_key.'?edition='.$edition->id : null; $result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at, - 'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->demographic_import_id ?? null) ? '/management/editorial/demography/'.$edition->demographic_import_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id)).'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no]; + 'href' => $subjectHref ?? ($notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->demographic_import_id ?? null) ? '/management/editorial/demography/'.$edition->demographic_import_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id)).'?edition='.$notice->edition_id), 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no]; if (count($result) === 50) { break; } @@ -251,7 +364,9 @@ final class PortalEditorialWorkflow return null; } - return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor) + $this->demographyPending($actor), 'unread' => $this->unreadCount($actor)]; + $native = app(PortalEditorialNativeNotices::class)->pending($actor); + + return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor) + $this->demographyPending($actor) + $this->subjectPending($actor) + $native['clusters'] + $native['territories'] + $native['zones'], 'unread' => $this->unreadCount($actor)]; } /** @param array $resource @@ -450,6 +565,11 @@ final class PortalEditorialWorkflow */ private function refreshPayload(\stdClass $edition, array $payload): array { + if ($edition->subject_kind ?? null) { + $subject = $payload['resource']['_subject']; + + return $this->subjectSnapshot($edition->subject_kind, $edition->subject_key, $subject['fields'], $payload['contributors'], $subject['new_record'] ?? false, $payload['policy']['independent_required'], $subject['withdraw'] ?? false); + } if ($edition->demographic_import_id ?? null) { return $this->demographySnapshot($edition->demographic_import_id, $payload['contributors'], $payload['policy']); } @@ -697,10 +817,12 @@ final class PortalEditorialWorkflow } /** @param array $input */ - public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false): \stdClass + public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false, ?string $subject = null): \stdClass { - abort_if($portfolio && $demography, 422); - if ($demography) { + abort_if(count(array_filter([$portfolio, $demography, $subject !== null])) > 1, 422); + if ($subject !== null) { + app(PortalEditorialSubjects::class)->authorize($actor, $subject, (string) $resource, true); + } elseif ($demography) { $this->authorizeDemography($actor, (string) $resource, true); } elseif ($portfolio) { $this->authorizePortfolio($actor, (string) $resource, true); @@ -713,18 +835,25 @@ final class PortalEditorialWorkflow } $data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => $reason, 'assigned_to' => 'nullable|integer|exists:users,id'])->validate(); - return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio, $demography) { - $r = DB::table($demography ? 'demographic_imports' : ($portfolio ? 'investment_dossiers' : 'portal_resources'))->where('id', $resource)->lockForUpdate()->first(); - $e = DB::table('portal_editorial_editions')->where('id', $id)->where($demography ? 'demographic_import_id' : ($portfolio ? 'dossier_id' : 'resource_id'), $resource)->lockForUpdate()->first(); + return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio, $demography, $subject) { + if ($subject !== null) { + $candidate = DB::table('portal_editorial_editions')->where('subject_kind', $subject)->where('subject_key', $resource)->where('id', $id)->first(); + abort_unless($candidate !== null, 404); + $r = app(PortalEditorialSubjects::class)->lock($subject, (string) $resource, $this->payload($candidate)['resource']['_subject']['fields']); + $e = DB::table('portal_editorial_editions')->where('id', $id)->lockForUpdate()->first(); + } else { + $r = DB::table($demography ? 'demographic_imports' : ($portfolio ? 'investment_dossiers' : 'portal_resources'))->where('id', $resource)->lockForUpdate()->first(); + $e = DB::table('portal_editorial_editions')->where('id', $id)->where($demography ? 'demographic_import_id' : ($portfolio ? 'dossier_id' : 'resource_id'), $resource)->lockForUpdate()->first(); + } abort_unless($r && $e, 404); - $latest = $demography ? $this->latestDemography((string) $resource) : ($portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource)); + $latest = $subject !== null ? $this->latestSubject($subject, (string) $resource) : ($demography ? $this->latestDemography((string) $resource) : ($portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource))); abort_unless((int) $e->version === (int) $data['version'] && $latest?->id === $id, 409, 'Versão desactualizada.'); $action = $data['action']; if (! in_array($action, ['submit', 'archive'], true)) { - abort_unless(app(DocumentManagementAccess::class)->full($actor), 403, 'Responsável de revisão/publicação necessário.'); + abort_unless($subject !== null ? $actor->can('manage-portal') : app(DocumentManagementAccess::class)->full($actor), 403, 'Responsável de revisão/publicação necessário.'); } if ($action === 'archive') { - abort_unless((int) $e->author_id === $actor->id || app(DocumentManagementAccess::class)->full($actor), 403); + abort_unless((int) $e->author_id === $actor->id || ($subject !== null ? $actor->can('manage-portal') : app(DocumentManagementAccess::class)->full($actor)), 403); } $allowed = match ($action) { 'submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'return', 'reject', 'legal_validate', 'approve' => ['in_review'], 'publish' => ['approved'], 'archive' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], @@ -737,7 +866,7 @@ final class PortalEditorialWorkflow abort_unless(hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $payload))), 409, 'Conteúdo, contexto ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.'); } $updates = []; - if ((in_array($action, ['approve', 'legal_validate'], true) || ($portfolio && $action === 'publish')) && $this->requiresIndependent($e)) { + if ((in_array($action, ['approve', 'legal_validate'], true) || (($portfolio || $subject !== null) && $action === 'publish')) && $this->requiresIndependent($e)) { abort_if(in_array($actor->id, $payload['contributors'], true), 403, 'Esta edição exige revisão independente.'); } if (in_array($action, ['approve', 'legal_validate'], true)) { @@ -746,7 +875,7 @@ final class PortalEditorialWorkflow } } if ($action === 'legal_validate') { - abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica explícita necessária.'); + abort_unless($this->legalReviewer($actor), 403, 'Designação jurídica explícita necessária.'); $updates['legal_validation'] = json_encode($this->decision($actor, $e, $data['reason']), JSON_THROW_ON_ERROR); } if ($action === 'approve') { @@ -760,7 +889,9 @@ final class PortalEditorialWorkflow if ($e->legal_required) { $this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true); } - if ($demography) { + if ($subject !== null) { + app(PortalEditorialSubjects::class)->publish($actor, $r, $e, $payload); + } elseif ($demography) { $this->publishDemography($actor, $r, $e, $payload, $data['reason']); } elseif ($portfolio) { $this->publishPortfolio($actor, $r, $e, $payload); @@ -771,12 +902,14 @@ final class PortalEditorialWorkflow } if ($action === 'submit' && isset($data['assigned_to'])) { $responsible = User::query()->whereKey($data['assigned_to'])->firstOrFail(); - abort_unless(app(DocumentManagementAccess::class)->full($responsible) && ! app(PortalProfileAccess::class)->has($responsible, 'auditor'), 422, 'Responsável sem autoridade editorial.'); + abort_unless(($subject !== null ? $responsible->can('manage-portal') : app(DocumentManagementAccess::class)->full($responsible)) && ! app(PortalProfileAccess::class)->has($responsible, 'auditor'), 422, 'Responsável sem autoridade editorial.'); abort_if($portfolio && ! $responsible->can('manage-portal'), 422, 'Responsável sem autoridade sobre a carteira.'); $updates['assigned_to'] = $responsible->id; } if ($action === 'archive' && $e->state === 'published') { - if ($demography) { + if ($subject !== null) { + app(PortalEditorialSubjects::class)->archive($actor, $r, $e); + } elseif ($demography) { $this->demographyStatus($actor, $r, 'disabled', $data['reason'], $e->id); } elseif ($portfolio) { DB::table('investment_dossiers')->where('id', $resource)->update(['publication_status' => 'draft', 'version_no' => (int) $r->version_no + 1, 'updated_at' => now()]); @@ -806,10 +939,10 @@ final class PortalEditorialWorkflow $d = $decision ? json_decode($decision, true, 512, JSON_THROW_ON_ERROR) : []; abort_unless(($d['edition_id'] ?? null) === $e->id && ($d['hash'] ?? null) === $e->content_hash, 422, $message); $actor = User::query()->whereKey($d['actor_id'] ?? 0)->first(); - abort_unless($actor && $actor->hasVerifiedEmail() && app(DocumentManagementAccess::class)->full($actor) && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403, 'Autoridade da decisão revogada.'); + abort_unless($actor && $actor->hasVerifiedEmail() && (($e->subject_kind ?? null) ? $actor->can('manage-portal') : app(DocumentManagementAccess::class)->full($actor)) && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403, 'Autoridade da decisão revogada.'); abort_if(($e->dossier_id ?? null) && ! $actor->can('manage-portal'), 403, 'Autoridade sobre a carteira revogada.'); if ($legal) { - abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica revogada.'); + abort_unless($this->legalReviewer($actor), 403, 'Designação jurídica revogada.'); } if ($this->requiresIndependent($e)) { abort_if(in_array($actor->id, $this->payload($e)['contributors'], true), 403, 'A decisão exige aprovação independente.'); @@ -850,7 +983,7 @@ final class PortalEditorialWorkflow private function audit(User $actor, \stdClass $e, string $action, string $reason, ?string $nextState = null): void { $current = DB::table('portal_editorial_editions')->where('id', $e->id)->first(); - app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'demographic_import_id' => $e->demographic_import_id ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version, + app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'demographic_import_id' => $e->demographic_import_id ?? null, 'subject_kind' => $e->subject_kind ?? null, 'subject_key' => $e->subject_key ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version, 'from_state' => $e->state, 'to_state' => $nextState ?? $e->state, 'result_version' => $current->version, 'approval' => $current->approval, 'legal_validation' => $current->legal_validation, 'reason' => $reason]); } } diff --git a/app/Services/TerritorialRegistry.php b/app/Services/TerritorialRegistry.php index 1d25c9fb..9cfc6323 100644 --- a/app/Services/TerritorialRegistry.php +++ b/app/Services/TerritorialRegistry.php @@ -234,8 +234,10 @@ final class TerritorialRegistry $version = (int) $state['location']['version_no']; $revision = $this->snapshot($id, $version, $actor, $reason, $state); $checksum = DB::table('geo_territory_revisions')->where('id', $revision)->value('checksum'); - DB::table('audit_events')->insert(['source_system' => self::EDITORIAL_SOURCE, 'entity_type' => 'geo_locations', 'entity_id' => $id, 'actor_user_id' => $actor->id, + $eventId = (string) Str::uuid(); + DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::EDITORIAL_SOURCE, 'entity_type' => 'geo_locations', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode(['version_no' => $version, 'revision_id' => $revision, 'checksum' => trim($checksum), 'state' => $state['profile']['status']], JSON_THROW_ON_ERROR)]); + app(PortalEditorialNativeNotices::class)->emit($actor, $eventId, $state['_editorial']['contributors'] ?? []); } /** @param array $data */ diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php index 86e4b9b2..c6c2af86 100644 --- a/config/editorial_workflow.php +++ b/config/editorial_workflow.php @@ -1,5 +1,7 @@ env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false), @@ -7,8 +9,11 @@ return [ 'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_ENABLED', false), 'demography' => env('PORTAL_EDITORIAL_DEMOGRAPHY_ENABLED', false), 'territorial_versions' => env('PORTAL_EDITORIAL_TERRITORIAL_ENABLED', false), + 'structural_subjects' => env('PORTAL_EDITORIAL_STRUCTURES_ENABLED', false), + 'zone_review' => env('PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED', false), // Legal designation is explicit; management authority alone is not a legal designation. 'legal_reviewers' => [], + 'legal_reviewer_emails' => is_string($legalReviewerEmails) ? array_values(array_filter(array_map(static fn (string $email): string => strtolower(trim($email)), explode(',', $legalReviewerEmails)))) : [], // Required separation is a policy, distinct from the legacy fact "independent_review". 'independent_approval' => ['resources' => [], 'kinds' => []], ]; diff --git a/database/migrations/2026_11_14_120000_extend_editorial_notices_to_native_revisions.php b/database/migrations/2026_11_14_120000_extend_editorial_notices_to_native_revisions.php new file mode 100644 index 00000000..91123226 --- /dev/null +++ b/database/migrations/2026_11_14_120000_extend_editorial_notices_to_native_revisions.php @@ -0,0 +1,35 @@ +foreignUuid('native_event_id')->nullable()->constrained('audit_events')->restrictOnDelete(); + $table->unique(['user_id', 'native_event_id', 'action'], 'editorial_native_notice_unique'); + }); + DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject, ADD CONSTRAINT portal_editorial_notice_subject CHECK (num_nonnulls(edition_id,opportunity_id,native_event_id)=1 AND (opportunity_id IS NULL OR opportunity_version IS NOT NULL))'); + } + + public function down(): void + { + if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('native_event_id')->exists()) { + throw new RuntimeException('Preserve native editorial notifications and history.'); + } + DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject, ADD CONSTRAINT portal_editorial_notice_subject CHECK ((edition_id IS NOT NULL AND opportunity_id IS NULL) OR (edition_id IS NULL AND opportunity_id IS NOT NULL AND opportunity_version IS NOT NULL))'); + Schema::table('portal_editorial_notifications', function (Blueprint $table): void { + $table->dropUnique('editorial_native_notice_unique'); + $table->dropForeign(['native_event_id']); + $table->dropColumn('native_event_id'); + }); + } +}; diff --git a/database/migrations/2026_11_14_123000_extend_editorial_editions_to_structural_subjects.php b/database/migrations/2026_11_14_123000_extend_editorial_editions_to_structural_subjects.php new file mode 100644 index 00000000..d1f1b894 --- /dev/null +++ b/database/migrations/2026_11_14_123000_extend_editorial_editions_to_structural_subjects.php @@ -0,0 +1,35 @@ +string('subject_kind', 40)->nullable(); + $table->uuid('subject_key')->nullable(); + $table->unique(['subject_kind', 'subject_key', 'revision_no'], 'editorial_subject_revision_unique'); + }); + DB::statement("ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id,subject_key)=1 AND ((subject_kind IS NULL)=(subject_key IS NULL)) AND (subject_kind IS NULL OR subject_kind IN ('projects','clusters','corridors','cluster-project','corridor-project','corridor-cluster','value-chains','value-chain-links','investment-gaps','zone-relations')))"); + } + + public function down(): void + { + if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('subject_kind')->exists()) { + throw new RuntimeException('Preserve structural editions and decisions.'); + } + DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id)=1)'); + Schema::table('portal_editorial_editions', function (Blueprint $table): void { + $table->dropUnique('editorial_subject_revision_unique'); + $table->dropColumn(['subject_kind', 'subject_key']); + }); + } +}; diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 9e2280e7..6f9b5692 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,3 +1,20 @@ +## 14/11/2026 — complemento único do Dashboard Colaborativo em QA + +**Estado:** a homologação mantém a entrega confirmada `d5c960b6`. Este complemento está implementado e verificado em QA, sem instalação. A nomeação do validador jurídico foi recebida da Tempo; não permanece como decisão organizacional em falta. A configuração nominal será aplicada apenas na entrega conjunta, conservando os restantes âmbitos da conta. + +- **Fila e notificações:** Competitividade, perfis territoriais e revisão de zonas reutilizam a caixa editorial comum. As notificações identificam o registo e a versão, não concedem acesso; a leitura volta a conferir o âmbito actual. Contadores excluem publicações/arquivos e não adoptam automaticamente fontes ou zonas antigas. +- **Estruturas e cadeias:** projectos, clusters e corredores sem ficha, relações entre eles e com zonas, etapas, ligações e lacunas usam as mesmas edições, decisões e auditoria. Não se cria outro dossier. Alterar conserva a publicação anterior; novos elementos de cadeia permanecem privados. Retirar exige proposta e publicação expressas. Conflitos de versão, relações repetidas, contexto territorial alterado, datas ou montantes incompatíveis não sobrescrevem os registos. +- **Outras estatísticas existentes:** observações e indicadores documentais das zonas reutilizam o rascunho encriptado, revisões e projecção pública nativos. Conservar valor decimal, unidade, período, âmbito e fonte; revisão → aprovação → publicação são operações distintas. Alterações no conteúdo, contexto, anexos ou autoridade invalidam a decisão. Consultar uma revisão recupera a descrição e observações exactas sem alterar a edição actual. Agregados privados de origem/análise proactiva continuam derivados dos dados autorizados; não se inventa um conjunto editorial público nem totais em falta. +- **Legislação:** `PORTAL_EDITORIAL_LEGAL_REVIEWERS` permite a designação nominal recebida, com correio verificado e âmbito documental/MFA existentes. Não concede gestão geral, não aprova versões anteriores e não atribui autoridade jurídica sobre zonas ou cálculos. + +**Utilização após instalação:** abrir `/management/editorial`; as filas existentes continuam disponíveis. Aceder a **Estruturas e relações** em `/management/editorial/subjects`, escolher o registo e usar **Alterar proposta**; guardar → submeter → iniciar revisão → aprovar → publicar. O editor nativo continua a ser a entrada para criar os registos. Nas zonas, abrir `/management/investment/zones/{id}`, guardar a preparação, usar **Revisão da zona e dos indicadores** e publicar separadamente. **Histórico editorial → Conferir versão** é consulta privada, sem restauração automática. Todos os endpoints, escolhas de catálogo, pré-visualizações e decisões conferem permissões no servidor. + +**Verificação do delta:** `outputs/editorial-completion-20261114` contém os recibos. Testes focados finais 28/361, regressão nativa afectada 35/540, frontend 13, tipos e compilação aprovados. A análise estática dos ficheiros alterados passou após correcções localizadas. Duas migrações QA adicionam três colunas e respectivas restrições/índices, sem backfill de conteúdos ou permissões. SQL nominal ensaiado numa cópia isolada no cluster 55441, comparado com o QA original; repetição recusada sem alterações. Operações no destino activo: zero neste complemento. + +**Entrega única e recuperação:** preservar os pacotes anteriores. Instalar apenas a candidata conjunta após autorização concreta, pelo executor Git existente, com backup/restauro conferido, contrato nominal das duas migrações e activação de `PORTAL_EDITORIAL_STRUCTURES_ENABLED` e `PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED`. Configurar o validador nominal protegido na mesma passagem. Não executar migrações indiscriminadamente, republicar fontes ou alterar permissões gerais. Após escritas nas novas versões, conservar colunas e histórico; usar reparação compatível em vez de remover as migrações. Restauro integral apenas antes de novas operações, com conferência de dados. + +**Conferência visual:** em 14/11/2026 a enumeração identificou Edge/Firefox como aplicações, mas devolveu `Browsers: Error: The browser access host is unavailable`, sem abas nem URL verificáveis. Não se repetiram reinícios nem se alteraram portas, permissões ou segurança. Permanece por conferir, na candidata QA autenticada, fila → proposta → decisão → publicação, notificações/histórico e janela estreita. Não declarar esta conferência aprovada nem a instalação deste complemento concluída. O fecho deste dashboard não declara concluídas as restantes funcionalidades do portal. + ## 14/11/2026 — pacote editorial instalado e activo na homologação Instalação conjunta expressamente autorizada pelo proprietário, incluindo as quatro alterações aditivas de estrutura. Revisão efectivamente servida e conferida em `https://teste-investidor.tempo.co.mz/build/revision.json`: **d5c960b62f97f04fb41acf71197ad2e7abbdf099**. Integra a candidata editorial `b3d4b7ab` e preserva as alterações fiscais/mapa aceites até `e4735424`, mediante duas integrações sem conflitos de código editorial. O Git/webhook/executor existentes fizeram a compilação e a substituição atómica; nenhuma edição manual da release servida. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index e0b80072..aed1fc63 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -11128,8 +11128,8 @@ AUD-SIM-L1-P133,Saída de regimes suspensivos e operações de viagens,Refinamen INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade existente, importador em duas etapas, séries/versionamento, comparação territorial e adenda nacional.",Pedido colado pelo proprietário nesta sessão; execução expressamente confirmada; Macate/Vanduzi autorizados apenas em QA em 13/11/2026.,Território; viabilidade; percurso; SWOT; simulador,/localizacoes/demografia; /api/v1/investment/demography; /management/demography,DemographicImport; DemographicEvidence; leitor XLSX; cadastro/mapa/comparação/simulador e fontes SWOT existentes reutilizados.,"Homologação:4988ca89 instalado,erro500 de processos corrigido;preparaçõesCLI12/920456 privadas,por confirmaçãoMFA. Local:12lotes/920456 instalados e verificados.","154706 observações reais INE; três originais com scan real/hash; repetição sem duplicação; 72 reconciliações entre exemplares; comparação GONDOLA/GURO. Homologação: backup/restauro190 tabelas/369 ficheiros;11 tabelas de negócio preservadas;168 correspondências+15 distritos autorizados,sem pendências;HTTPS200 e revisão b20068d0 confirmada. Correcção df543a44:3testes/17asserções;HTTPS confirma revisão;ambos os titulares designados passam a autorização de leitura;nenhuma permissão alterada;hashes de código/configuração conferidos. Correcção500:2testes/20asserções sem proc_open;14testes/52asserções consumidores;8preparador;PHPStan aprovado. PHP-FPM real confirmou processos bloqueados,cacheCLI e antimalware disponíveis,hashes correctos,sem actor/sessão ou escrita de negócio;HTTPS confirma4988ca89.",Código instalado e verificado no PHP-FPM real;incorporação remota dos dados pendente da confirmação autenticada.,"Confirmar coortes initial/remaining/zambezia na sessão com MFA e conferir recibos,12 lotes/920456 observações e consulta territorial. Conferência visual administrativa pendente.",PostgreSQL/PostGIS QA; ClamAV; Python stdlib; autenticação e MFA existentes; nenhum novo aprovador.,"Três ficheiros associados aos territórios, valores exactos, séries 2017–2050, consulta sectorial/temporal, duas localizações, fontes/versões, inconsistências e acessos demonstrados em QA. Homologação manual restante registada no relatório.",Alta,,,,,"Consulta pública desktop/mobile conferida; visual administrativa pendente, testes HTTP aprovados.",11 testes de integração aprovados.,Três lotes QA; 154706 observações; idempotência e unicidade verificadas.,Consulta por território/ano/exemplar; desactivação/reaprovação preservam histórico.,"População/idade/residência, vitais, fontes e limitações; sem extrapolação, procura automática ou probabilidade de sucesso.",,,,,"Sem pendência de importação dos ficheiros provinciais fornecidos. Conferência visual administrativa,ressalvas/unidades ausentes e indicadores sem valores mantidos;outros ambientes não instalados por esta operação.","PortalProfileInstallation/MFA existentes; DemographicInstallation; DemographicImport; InvestmentGeography; simulador, mapa/comparação e fontes SWOT existentes.",,Proprietário executou a disponibilização autorizada de Zambezia.xlsx e cinco distritos;recibo autenticado e resultado confirmados sem repetição.,,,"outputs/demography-20261113/zambezia/installation-status.json e verification.json:recibo autenticado09:33:38UTC,hash/lote/count126684,total12lotes/920456,preservação11lotes e oito tabelas de negócio,série provincial e cinco distritos públicos. operational-status.json actualizado;ensaios e backup/restauro anteriores preservados.",demografia-ine,13/11/2026 — instalação autenticada concluída; conferência técnica de leitura sem repetição da operação,,,,,,,,,,,,,, REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzidos pelas etapas, com versão, oportunidade e recuperação.",Pedido expresso do proprietário em 13/11/2026: «tem de ser resolvido».,Percurso do investidor; dossier; relatórios,/investimentos/{id}#relatorios; /investimentos/{id}/relatorios/fiscais/{reportId},"Índice e relatório consolidado no percurso existente; reutiliza os geradores Word/HTML, cálculos e cópias preservadas.",Homologação teste-investidor.tempo.co.mz instalada em 8854b292; /investimentos/{id}#relatorios.,"21 testes frontend; regressão servidor 4/168 asserções e caso final 45 asserções; tipos,lint,PHPStan e build aprovados. HTTPS e preservação conferidos na revisão instalada.",Instalado e conferido em 8854b292; resumos S01–S19 consultáveis e descarregáveis,QA: 19 consultas e 38 descargas Word/HTML; recuperação demonstrada. Instalação e consulta/descarga S01 conferidas na homologação. Saídas específicas ainda não produzidas mantêm o saldo próprio.,,"Mesmo dossier/oportunidade → índice por etapa → relatório Word/HTML → acesso a versões APIEX, fiscais e documentos → recuperação sem misturar oportunidades ou expor finanças a gestores.",Alta,,,,,Índice responsivo e exportação consolidada,"Descarga privada da cópia fiscal preservada, autorizada por titular e dossier","Reutiliza os registos existentes; associação fiscal nova conserva âmbito, sem reescrever fontes anteriores.",Aprovada em prova sintética; sem depender de acesso à simulação anónima original.,Versão/data/estado/origem; pendências e âmbito por confirmar explícitos.,,,,,,,,"Acrescentar índice e consolidar saídas existentes; sem módulo paralelo, publicação ou envio automático.",Resumos por etapa instalados e conferidos; originais específicos em falta mantêm o saldo próprio,,outputs/dossier-reports-20261113/verification.json; outputs/dossier-reports-20261113/homologation-installed.json; outputs/dossier-reports-20261113/stage-authenticated-download-verification.json; outputs/dossier-reports-20261113/stage-homologation-installed.json,,,,,,,,,,,,,,,, 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,"Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","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; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend",Instalado na homologação; conferência visual pendente; designação jurídica nominal pendente para novas versões que a exijam,Conferência visual; designação jurídica nominal para novas versões que a exijam; restantes resíduos da Fase 2,Instalação/schema concluídos; falta conferência visual; designação jurídica explícita para validação de novas versões legais,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,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,Revisão d5c960b6 confirmada no URL servido; componentes activos e dados preservados; não confundir instalação com conferência visual,Conferência visual; designação jurídica nominal para novas versões que a exijam; restantes resíduos da Fase 2,"DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",Conferência visual e resíduos funcionais documentados; instalação/schema deixam de ser pendência deste recorte,Implementar em desenvolvimento/QA; não intervir em produção,Implementação aditiva e testes focados,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo Fase 1,14/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 autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,Instalado e activo d5c960b6; conferência visual pendente; restantes resíduos preservados no plano -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","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend",Instalado na homologação; conferência visual pendente,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas",Instalação/schema concluídos; falta conferência visual,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,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,Revisão d5c960b6 confirmada no URL servido; componentes activos e dados preservados; não confundir instalação com conferência visual,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas","ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",Conferência visual e resíduos funcionais documentados; instalação/schema deixam de ser pendência deste recorte,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,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/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 autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,Instalado e activo d5c960b6; conferência visual pendente; restantes resíduos preservados no plano -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,"Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","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; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend",Instalado na homologação; conferência visual pendente,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas",Instalação/schema concluídos; falta conferência visual,"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,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,Revisão d5c960b6 confirmada no URL servido; componentes activos e dados preservados; não confundir instalação com conferência visual,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",Conferência visual e resíduos funcionais documentados; instalação/schema deixam de ser pendência deste recorte,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/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 conferência visual autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,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,Instalado e activo d5c960b6; conferência visual pendente; restantes resíduos preservados no plano -ACT-20261114-EDITORIAL-DEMO,Novos lotes demográficos no circuito editorial: aprovação separada da publicação; preservar lotes e valores anteriores; entrega agrupada,"Fase 2 delimitada e directiva de continuidade; decisão de pacote agrupado, 13–14/11/2026",Dashboard Colaborativo / indicadores demográficos,"/management/demography; /management/editorial/demography/{id}; versões, transições e original protegido","Lote nativo privado mantém imported até publicação explícita; edições e decisões comuns; originais, proveniência, dados e territórios por hash; MFA e autoridade existentes; notificações e pendências","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","outputs/editorial-demography-20261114: seis casos iniciais, incluindo 131400 observações/256MB; cinco funcionais reforçados 5/66; regressão 14/52; frontend 24; PHPStan/tipos/build aprovados; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend",Instalado na homologação; conferência visual pendente,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas",Instalação/schema concluídos; falta conferência visual,Aprovar não publica; original/observações/proveniência alterados impedem publicação e reaprovação silenciosa; contexto territorial exige revisão; publicar conserva séries anteriores; arquivo conserva valores e histórico; MFA/autoridade/independência quando exigida; memória limitada,Alta,,,,,"Gestão nativa e revisão comum, originais, advertências, edições, pendências e notificações",JSON/HTML protegidos; original privado; anti-bypass das operações antigas,Edições na tabela comum e eventos demográficos nativos; observações preservadas,Histórico e fontes recuperáveis sem repetir importação; dados anteriores preservados,A série só aparece nas análises existentes depois da publicação explícita,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,Revisão d5c960b6 confirmada no URL servido; componentes activos e dados preservados; não confundir instalação com conferência visual,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas","DemographicImport, DemographicEvidence, PortalEditorialWorkflow, importações/observações/eventos existentes, perfis e MFA",Conferência visual e resíduos funcionais documentados; instalação/schema deixam de ser pendência deste recorte,"Implementar em QA, conservar fontes aceites e agrupar a instalação; sem intervenção operacional",Adaptação no circuito comum; nenhuma nova tabela de observações ou motor paralelo,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/11/2026,,,,,,,,"Regressão demográfica 14/52; Fase 1, carteira e dados-base reaproveitados/repetidos apenas no código afectado","Separação aprovação/publicação; fontes, números, advertências, jurisdição, acesso, memória e preservação histórica","Sem conferência visual autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Publicação editorial de indicadores demográficos,"Lote privado → revisão → aprovação → publicação → análise existente → arquivo, sem alterar valores ou lotes anteriores",Instalado e activo d5c960b6; conferência visual pendente; restantes resíduos preservados no plano -ACT-20261114-EDITORIAL-TERRITORIO,Revisão territorial privada sem retirar ou substituir a versão pública; pacote editorial conjunto,"Dashboard Colaborativo, Fase 2 delimitada e directiva de continuidade, 13–14/11/2026",Dashboard Colaborativo / registo territorial,/management/territorial/editorial; /api/v1/geo/drafts/{id}; APIs territoriais existentes,Revisões encriptadas nativas; proposta privada; revisão independente e terceiro publicador; publicação atómica e histórico; recibos idempotentes,"Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","outputs/editorial-territorial-20261114: integração 5/67; regressão nativa 5/44; frontend, PHPStan, tipos e build; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend",Instalado na homologação; conferência visual pendente,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas",Instalação/schema concluídos; falta conferência visual,Guardar/rever/aprovar preserva publicação; só publicar aplica; exactidão da versão/fontes/hierarquia; independência; retirada conserva revisão; histórico privado; erros preservam edições,Alta,,,,,"Ecrã de revisão, lista paginada, comparação pública, fonte, validade, decisões separadas e histórico",Permissões/MFA no servidor e antes de replay; pré-visualização privada; sem acesso público a propostas,Revisões encriptadas e imutáveis existentes; publicação transaccional; dados públicos preservados,Versão privada e histórico recuperáveis; contador optimista; fontes alteradas invalidam aprovação,Publicação anterior consultável durante a revisão; alteração apenas após decisão expressa,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,Revisão d5c960b6 confirmada no URL servido; componentes activos e dados preservados; não confundir instalação com conferência visual,"Conferência visual; restantes resíduos do módulo/Fase 2 explicitados no manual, incluindo notificações próprias e estruturas/relações ainda não cobertas","TerritorialRegistry, fontes/perfis/revisões/recibos nativos, papéis territoriais, MFA, auditoria e Dashboard editorial",Conferência visual e resíduos funcionais documentados; instalação/schema deixam de ser pendência deste recorte,"Continuar em QA sem alterar produção, dados oficiais ou papéis existentes; agrupar entregas compatíveis",Extensão do circuito territorial nativo; sem motor paralelo,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/11/2026,,,,,,,,Fontes e lotes demográficos aceites preservados; regressão nativa apenas no código afectado,"Versões pública/privada, aprovação/publicação separadas, idempotência, MFA, independência, conflitos e histórico","Sem conferência visual autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Versionamento e circuito editorial territorial,"Publicação → proposta privada → revisão → aprovação → publicação → recuperação, preservando dados e histórico",Instalado e activo d5c960b6; conferência visual pendente; restantes resíduos preservados no plano +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 [Complemento agrupado QA 14/11/2026] Designação jurídica explícita por configuração protegida, sem conceder gestão geral; nomeação recebida da Tempo. Adaptadores nativos na mesma caixa de notificações e contadores, com âmbito actual e deduplicação.","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","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; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend; complemento outputs/editorial-completion-20261114: testes focados finais 28/361; regressão nativa 35/540; frontend 13; tipos/build aprovados; análise estática aprovada após correcções localizadas; duas alterações aditivas ensaiadas no QA55441, preservação e repetição recusada","Base instalada d5c960b6; complemento implementado e verificado em QA, sem instalação; visual pendente","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.",Instalação/schema concluídos; falta conferência visual; designação jurídica explícita para validação de novas versões legais,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,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,"Instalação anterior d5c960b6 preservada. Designação jurídica explícita por configuração protegida, sem conceder gestão geral; nomeação recebida da Tempo. Adaptadores nativos na mesma caixa de notificações e contadores, com âmbito actual e deduplicação. O complemento não está na homologação; provas QA não substituem conferência visual.","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.","DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",Autorização concreta da nova entrega conjunta e transporte autenticado para instalação; conector visual: The browser access host is unavailable (14/11/2026). A nomeação jurídica já foi recebida.,Implementar em desenvolvimento/QA; não intervir em produção,Instalação agrupada e conferência visual do delta,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo Fase 1,14/11/2026 — complemento delimitado do dashboard; provas aceites preservadas,,,,,,,,"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 autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,"Base instalada d5c960b6; complemento agrupado QA, pronto para autorização de entrega; visual bloqueado pelo conector" +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 [Complemento agrupado QA 14/11/2026] Notificações próprias de Competitividade e contadores; estruturas sem ficha, relações, etapas, ligações e lacunas reutilizam as edições e decisões comuns. Nenhum dossier paralelo; publicação anterior conservada.","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend; complemento outputs/editorial-completion-20261114: testes focados finais 28/361; regressão nativa 35/540; frontend 13; tipos/build aprovados; análise estática aprovada após correcções localizadas; duas alterações aditivas ensaiadas no QA55441, preservação e repetição recusada","Base instalada d5c960b6; complemento implementado e verificado em QA, sem instalação; visual pendente","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.",Instalação/schema concluídos; falta conferência visual,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,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,"Instalação anterior d5c960b6 preservada. Notificações próprias de Competitividade e contadores; estruturas sem ficha, relações, etapas, ligações e lacunas reutilizam as edições e decisões comuns. Nenhum dossier paralelo; publicação anterior conservada. O complemento não está na homologação; provas QA não substituem conferência visual.","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.","ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",Autorização concreta da nova entrega conjunta e transporte autenticado para instalação; conector visual: The browser access host is unavailable (14/11/2026). A nomeação jurídica já foi recebida.,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Instalação agrupada e conferência visual do delta,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/11/2026 — complemento delimitado do dashboard; provas aceites preservadas,,,,,,,,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 autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,"Base instalada d5c960b6; complemento agrupado QA, pronto para autorização de entrega; visual bloqueado pelo conector" +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 [Complemento agrupado QA 14/11/2026] Estruturas sem ficha e relações projecto/cluster/corredor/zona, cadeias e lacunas com proposta privada, revisão, publicação e retirada expressas; versões, fontes, localização e conflitos preservados.","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","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; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend; complemento outputs/editorial-completion-20261114: testes focados finais 28/361; regressão nativa 35/540; frontend 13; tipos/build aprovados; análise estática aprovada após correcções localizadas; duas alterações aditivas ensaiadas no QA55441, preservação e repetição recusada","Base instalada d5c960b6; complemento implementado e verificado em QA, sem instalação; visual pendente","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.",Instalação/schema concluídos; falta conferência visual,"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,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,"Instalação anterior d5c960b6 preservada. Estruturas sem ficha e relações projecto/cluster/corredor/zona, cadeias e lacunas com proposta privada, revisão, publicação e retirada expressas; versões, fontes, localização e conflitos preservados. O complemento não está na homologação; provas QA não substituem conferência visual.","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",Autorização concreta da nova entrega conjunta e transporte autenticado para instalação; conector visual: The browser access host is unavailable (14/11/2026). A nomeação jurídica já foi recebida.,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Instalação agrupada e conferência visual do delta,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/11/2026 — complemento delimitado do dashboard; provas aceites preservadas,,,,,,,,"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 conferência visual autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,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,"Base instalada d5c960b6; complemento agrupado QA, pronto para autorização de entrega; visual bloqueado pelo conector" +ACT-20261114-EDITORIAL-DEMO,Novos lotes demográficos no circuito editorial: aprovação separada da publicação; preservar lotes e valores anteriores; entrega agrupada,"Fase 2 delimitada e directiva de continuidade; decisão de pacote agrupado, 13–14/11/2026",Dashboard Colaborativo / indicadores demográficos,"/management/demography; /management/editorial/demography/{id}; versões, transições e original protegido","Lote nativo privado mantém imported até publicação explícita; edições e decisões comuns; originais, proveniência, dados e territórios por hash; MFA e autoridade existentes; notificações e pendências [Complemento agrupado QA 14/11/2026] Recorte demográfico anteriormente instalado preservado, sem reimportar, recalcular ou reaprovar os doze lotes. Outros indicadores documentais existentes tratados na revisão nativa de zonas, não num novo conjunto demográfico.","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","outputs/editorial-demography-20261114: seis casos iniciais, incluindo 131400 observações/256MB; cinco funcionais reforçados 5/66; regressão 14/52; frontend 24; PHPStan/tipos/build aprovados; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend; complemento outputs/editorial-completion-20261114: testes focados finais 28/361; regressão nativa 35/540; frontend 13; tipos/build aprovados; análise estática aprovada após correcções localizadas; duas alterações aditivas ensaiadas no QA55441, preservação e repetição recusada","Base instalada d5c960b6; complemento implementado e verificado em QA, sem instalação; visual pendente","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.",Instalação/schema concluídos; falta conferência visual,Aprovar não publica; original/observações/proveniência alterados impedem publicação e reaprovação silenciosa; contexto territorial exige revisão; publicar conserva séries anteriores; arquivo conserva valores e histórico; MFA/autoridade/independência quando exigida; memória limitada,Alta,,,,,"Gestão nativa e revisão comum, originais, advertências, edições, pendências e notificações",JSON/HTML protegidos; original privado; anti-bypass das operações antigas,Edições na tabela comum e eventos demográficos nativos; observações preservadas,Histórico e fontes recuperáveis sem repetir importação; dados anteriores preservados,A série só aparece nas análises existentes depois da publicação explícita,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,"Instalação anterior d5c960b6 preservada. Recorte demográfico anteriormente instalado preservado, sem reimportar, recalcular ou reaprovar os doze lotes. Outros indicadores documentais existentes tratados na revisão nativa de zonas, não num novo conjunto demográfico. O complemento não está na homologação; provas QA não substituem conferência visual.","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.","DemographicImport, DemographicEvidence, PortalEditorialWorkflow, importações/observações/eventos existentes, perfis e MFA",Autorização concreta da nova entrega conjunta e transporte autenticado para instalação; conector visual: The browser access host is unavailable (14/11/2026). A nomeação jurídica já foi recebida.,"Implementar em QA, conservar fontes aceites e agrupar a instalação; sem intervenção operacional",Instalação agrupada e conferência visual do delta,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/11/2026 — complemento delimitado do dashboard; provas aceites preservadas,,,,,,,,"Regressão demográfica 14/52; Fase 1, carteira e dados-base reaproveitados/repetidos apenas no código afectado","Separação aprovação/publicação; fontes, números, advertências, jurisdição, acesso, memória e preservação histórica","Sem conferência visual autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Publicação editorial de indicadores demográficos,"Lote privado → revisão → aprovação → publicação → análise existente → arquivo, sem alterar valores ou lotes anteriores","Base instalada d5c960b6; complemento agrupado QA, pronto para autorização de entrega; visual bloqueado pelo conector" +ACT-20261114-EDITORIAL-TERRITORIO,Revisão territorial privada sem retirar ou substituir a versão pública; pacote editorial conjunto,"Dashboard Colaborativo, Fase 2 delimitada e directiva de continuidade, 13–14/11/2026",Dashboard Colaborativo / registo territorial,/management/territorial/editorial; /api/v1/geo/drafts/{id}; APIs territoriais existentes,"Revisões encriptadas nativas; proposta privada; revisão independente e terceiro publicador; publicação atómica e histórico; recibos idempotentes [Complemento agrupado QA 14/11/2026] Notificações próprias dos perfis territoriais; revisão nativa de zonas e respectivas observações/indicadores documentais, com fonte, período, unidade e âmbito, aprovação separada e recuperação histórica exacta.","Instalado e activo em teste-investidor.tempo.co.mz, d5c960b6; contas, dados e publicação anterior preservados","outputs/editorial-territorial-20261114: integração 5/67; regressão nativa 5/44; frontend, PHPStan, tipos e build; instalação 14/11 d5c960b6: backup/restauro 193 tabelas, quatro alterações aditivas, 416 uploads preservados, HTTP público/privado conferido; CI 132 unitários e 1902 frontend; complemento outputs/editorial-completion-20261114: testes focados finais 28/361; regressão nativa 35/540; frontend 13; tipos/build aprovados; análise estática aprovada após correcções localizadas; duas alterações aditivas ensaiadas no QA55441, preservação e repetição recusada","Base instalada d5c960b6; complemento implementado e verificado em QA, sem instalação; visual pendente","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.",Instalação/schema concluídos; falta conferência visual,Guardar/rever/aprovar preserva publicação; só publicar aplica; exactidão da versão/fontes/hierarquia; independência; retirada conserva revisão; histórico privado; erros preservam edições,Alta,,,,,"Ecrã de revisão, lista paginada, comparação pública, fonte, validade, decisões separadas e histórico",Permissões/MFA no servidor e antes de replay; pré-visualização privada; sem acesso público a propostas,Revisões encriptadas e imutáveis existentes; publicação transaccional; dados públicos preservados,Versão privada e histórico recuperáveis; contador optimista; fontes alteradas invalidam aprovação,Publicação anterior consultável durante a revisão; alteração apenas após decisão expressa,,,QA implementado; sem instalação operacional antes da entrega autorizada de 14/11/2026,"Instalação anterior d5c960b6 preservada. Notificações próprias dos perfis territoriais; revisão nativa de zonas e respectivas observações/indicadores documentais, com fonte, período, unidade e âmbito, aprovação separada e recuperação histórica exacta. O complemento não está na homologação; provas QA não substituem conferência visual.","Instalar uma única entrega complementar autorizada, incluindo duas alterações aditivas e configuração jurídica nominal; conferir visualmente em sessão autenticada e janela estreita. Não há publicação automática.","TerritorialRegistry, fontes/perfis/revisões/recibos nativos, papéis territoriais, MFA, auditoria e Dashboard editorial",Autorização concreta da nova entrega conjunta e transporte autenticado para instalação; conector visual: The browser access host is unavailable (14/11/2026). A nomeação jurídica já foi recebida.,"Continuar em QA sem alterar produção, dados oficiais ou papéis existentes; agrupar entregas compatíveis",Instalação agrupada e conferência visual do delta,,"Provas QA anteriores e outputs/editorial-agrupado-20261114/destination-installation-verified.json; recibos de backup, restauro, schema, activação e CI na mesma pasta",Dashboard Colaborativo — pacote conjunto,14/11/2026 — complemento delimitado do dashboard; provas aceites preservadas,,,,,,,,Fontes e lotes demográficos aceites preservados; regressão nativa apenas no código afectado,"Versões pública/privada, aprovação/publicação separadas, idempotência, MFA, independência, conflitos e histórico","Sem conferência visual autenticada; sem novos papéis, aprovação ou publicação de conteúdos; resíduos da Fase 2 mantidos; validação jurídica requer designação nominal",,Versionamento e circuito editorial territorial,"Publicação → proposta privada → revisão → aprovação → publicação → recuperação, preservando dados e histórico","Base instalada d5c960b6; complemento agrupado QA, pronto para autorização de entrega; visual bloqueado pelo conector" diff --git a/resources/js/pages/investment/zone.tsx b/resources/js/pages/investment/zone.tsx index 1eb87bdd..b47552a8 100644 --- a/resources/js/pages/investment/zone.tsx +++ b/resources/js/pages/investment/zone.tsx @@ -89,6 +89,15 @@ const labels: Record = { const label = (value?: string | null) => value ? (labels[value] ?? value) : 'Não informado'; type Zone = { + editorial_enabled?: boolean; + editorial_workflow?: { + state: string; + fresh: boolean; + content_version: number; + hash: string; + can_approve: boolean; + independent_required: boolean; + } | null; operational_details?: ParkDetails; operational_details_public?: ParkDetails; details_available?: boolean; @@ -248,6 +257,206 @@ type Props = { prev_page_url: string | null; } | null; }; +function ZoneHistory({ + id, + revisions, +}: { + id: string; + revisions: Props['revisions']; +}) { + const [selected, setSelected] = useState<{ + version_no: number; + fields: { name?: string; description?: string }; + observations: { + label?: string; + value?: string; + unit?: string; + scope?: string; + period_start?: string; + period_end?: string; + source_reference?: string; + }[]; + } | null>(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + async function inspect(version: number) { + setLoading(true); + setError(''); + setSelected(null); + try { + setSelected( + await api( + `/api/v2/investment/admin/zones/${id}/revisions/${version}`, + ), + ); + } catch (e) { + setError( + e instanceof Error + ? e.message + : 'Não foi possível conferir esta versão.', + ); + } finally { + setLoading(false); + } + } + return ( +
+

Histórico editorial

+ {revisions.map((r) => ( +

+ Versão {r.version_no} · {r.action} · {r.created_at}{' '} + +

+ ))} + {error &&

{error}

} + {selected && ( +
+

+ Consulta da versão {selected.version_no} +

+

+ Esta consulta preserva a versão actual e a preparação em + curso. +

+

{selected.fields.name}

+

+ {selected.fields.description} +

+ {selected.observations.map((row, index) => ( +
+ {row.label} +

+ {row.value} {row.unit} +

+

+ {row.scope} · {row.period_start} –{' '} + {row.period_end} +

+

{row.source_reference}

+
+ ))} +
+ )} +
+ ); +} + +function ZoneReview({ + zone, + busy, + dirty, + action, +}: { + zone: Zone; + busy: boolean; + dirty: boolean; + action: (url: string, data: unknown) => Promise; +}) { + const [reason, setReason] = useState(''); + const workflow = zone.editorial_workflow; + const labels: Record = { + submit: 'Submeter à revisão', + start_review: 'Iniciar revisão', + approve: 'Aprovar esta edição', + return: 'Devolver com fundamento', + reject: 'Rejeitar', + archive: 'Arquivar proposta privada', + }; + const states: Record = { + draft: 'Rascunho', + submitted: 'Submetido', + in_review: 'Em revisão', + approved: 'Aprovado', + returned: 'Devolvido', + rejected: 'Rejeitado', + archived: 'Arquivado', + }; + const actions: Record = { + not_started: ['submit'], + draft: ['submit', 'archive'], + submitted: ['start_review', 'archive'], + in_review: ['return', 'reject', 'approve', 'archive'], + approved: ['archive'], + returned: ['submit', 'archive'], + rejected: ['archive'], + archived: [], + }; + return ( +
+

+ Revisão da zona e dos indicadores +

+

+ {workflow + ? states[workflow.state] + : 'Prepare a revisão da edição actual.'}{' '} + A aprovação é separada da publicação. A apresentação anterior + mantém-se durante a revisão. +

+ {workflow && ( +

+ Edição do conteúdo: {workflow.content_version} · versão de + trabalho: {zone.version_no}. +

+ )} + {workflow && !workflow.fresh && ( +

+ O conteúdo, fonte ou anexos mudaram. Guarde uma nova + preparação e volte a conferir. +

+ )} + {dirty && ( +

Guarde as alterações antes de decidir sobre a edição.

+ )} + +