Compare commits

...
Author SHA1 Message Date
nala 96f510965f Separate dashboard functions from grouped administration operations
tests / ci (push) Waiting to run
2026-11-14 12:25:27 +02:00
nala 88f18d0b7e Record verified dashboard HTML recovery dd20e747 2026-11-14 12:13:54 +02:00
nala dd20e74703 Fix authenticated editorial dashboard missing Vite page entry
tests / ci (push) Waiting to run
2026-11-14 12:04:52 +02:00
nala 203c8f9193 Record verified dashboard menu deployment 73e1d7b9 2026-11-14 11:59:02 +02:00
nala 73e1d7b964 Group dashboard activities under primary administration and tidy editorial overview
tests / ci (push) Waiting to run
2026-11-14 11:43:56 +02:00
nala ce8b744d1f Record verified grouped dashboard installation a4541298 2026-11-14 11:31:38 +02:00
nala a4541298c0 Complete grouped collaborative editorial queues and native review coverage
tests / ci (push) Waiting to run
2026-11-14 11:15:09 +02:00
nala f40924f1be Record authorised grouped editorial installation and preservation receipts 2026-11-14 10:11:23 +02:00
nala d5c960b62f Merge remote-tracking branch 'origin/main' into qa/editorial-fase1-20261113
tests / ci (push) Waiting to run
2026-11-14 09:12:25 +02:00
nala fa69884ab0 Integrate authorised editorial package with current homologation changes 2026-11-14 09:11:55 +02:00
nala e473542494 Support documented full annual noncivil PPC PEC estimates and dossier continuity
tests / ci (push) Waiting to run
2026-11-14 09:05:33 +02:00
nala b3d4b7abe6 Preserve published territories during private editorial review 2026-11-14 09:03:05 +02:00
nala d3eeb70370 Record authorised period consistency installation and preserved data 2026-11-14 09:02:37 +02:00
nala 3c4c2599f7 feat(editorial): review demographic batches before explicit publication 2026-11-14 08:35:11 +02:00
nala 078f770dea Keep PPC PEC estimates and reports consistent with the prepared L6 period
tests / ci (push) Waiting to run
2026-11-14 00:23:46 +02:00
nala e9278ee446 Record authorised grouped PEC installation and preserved data 2026-11-14 00:00:21 +02:00
nala 2459d0ca37 feat(editorial): stage portfolio base data and territory for atomic publication 2026-11-13 23:47:04 +02:00
nala 95b33275aa Integrate monthly PPC PEC cash projections across screen and dossier report
tests / ci (push) Waiting to run
2026-11-13 23:32:00 +02:00
nala a045162773 Record authorised grouped PEC installation and preserved data 2026-11-13 23:29:17 +02:00
nala d2c72f5e13 feat(editorial): preserve public portfolio fiches during private review 2026-11-13 23:23:16 +02:00
nala f059c226d2 Group documented prior PEC basis and excess PPC reconciliation for viability
tests / ci (push) Waiting to run
2026-11-13 23:14:22 +02:00
nala 1218734eca feat(editorial): separate cluster analysis approval and publication in QA 2026-11-13 23:08:02 +02:00
nala d9f088587b Record authorised observation nature delivery and preserved data 2026-11-13 23:07:27 +02:00
nala b3849d850e feat(editorial): preserve public editions and add QA review workflow 2026-11-13 22:56:44 +02:00
nala 2bad72c042 Expose calendar duration and overlap before comparing territorial observations
tests / ci (push) Waiting to run
2026-11-13 22:54:49 +02:00
nala 39bddad091 Record authorised observation nature delivery and preserved data 2026-11-13 22:54:46 +02:00
nala 42b9324acb Classify sourced territorial observations without inferring legacy values
tests / ci (push) Waiting to run
2026-11-13 22:44:42 +02:00
nala 7e7bd4fde3 Record authorised territorial comparison delivery and preserved data 2026-11-13 22:09:58 +02:00
nala 1263b4f6b7 Compare sourced territorial observations without inferring statistical equivalence
tests / ci (push) Waiting to run
2026-11-13 21:52:38 +02:00
nala 2f1ef2fdfc Align quick search with current financial directory while retaining historical records
tests / ci (push) Waiting to run
2026-11-13 21:14:25 +02:00
nala 61b4d9f1f9 Record authorised stage report installation and preservation 2026-11-13 15:19:12 +02:00
nala 1d52c3d8f4 Preserve installed dossier and support presentation while delivering authorised quick search
tests / ci (push) Waiting to run
2026-11-13 15:17:26 +02:00
nala 0f79d0d54c Keep institutional contact test independent of CSS module loading in Node CI
tests / ci (push) Waiting to run
2026-11-13 15:09:08 +02:00
nala d325b72fad Improve investor quick search with typed counts, product categories and licensing guidance 2026-11-13 15:06:11 +02:00
nala 8854b2928c Allow private stage report preview and explicit Word/HTML download 2026-11-13 15:02:15 +02:00
paulomassigue f53a308327 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Waiting to run
2026-11-13 14:43:34 +02:00
paulomassigue eab09dd61e ajuste na pesquisa de informacao, para passar a dar prioridade 2026-11-13 12:07:51 +02:00
paulomassigue 71f2fcbb84 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2 into front 2026-11-13 09:54:01 +02:00
paulomassigue f6c1b60612 organizao de suporte 2026-11-12 11:26:33 +02:00
141 changed files with 13093 additions and 936 deletions
@@ -19,7 +19,7 @@ final class ClusterCompetitivenessController extends Controller
$data = $request->validate(['expected_version' => 'required|integer|min:0',
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited',
'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']);
'reason' => in_array($action, ['saved', 'restored'], true) ? 'nullable|string|max:3000' : 'required|string|min:10|max:3000']);
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
->header('Cache-Control', 'private, no-store');
@@ -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.');
+3 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
@@ -28,7 +29,7 @@ class DashboardController extends Controller
$contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
@@ -43,7 +44,7 @@ class DashboardController extends Controller
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
return Inertia::render('dashboard', ['workQueue' => $workQueue, 'summary' => [
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'workQueue' => $workQueue, 'summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -40,7 +41,10 @@ final class DemographyController extends Controller
$service->authorize($request->user());
$data = $request->validate(['batch' => 'nullable|uuid']);
return Inertia::render('management/demography', ['qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
$workflow = app(PortalEditorialWorkflow::class);
$editorial = $workflow->demographyAvailable();
return Inertia::render('management/demography', ['editorialEnabled' => $editorial, 'editorialRevision' => $editorial && isset($data['batch']) ? (int) ($workflow->latestDemography($data['batch'])->revision_no ?? 0) : 0, 'qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\DocumentManagementAccess;
use App\Services\EditorialInstallation;
use App\Services\EditorialReading;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
@@ -97,11 +98,17 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$r = $workflow->workingResource($r);
}
$reading = app(EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'workflowEnabled' => $workflow->enabled(), 'workflow' => $workflow->latest($id),
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -114,7 +121,9 @@ class EditorialArticleController extends Controller
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
$workflow = app(PortalEditorialWorkflow::class);
$current = $workflow->enabled() ? $workflow->workingResource($r) : $r;
abort_unless((int) $current->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
}
@@ -122,7 +131,13 @@ class EditorialArticleController extends Controller
if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
}
$content = $this->edition($r, $data);
$workflow = app(PortalEditorialWorkflow::class);
$content = $this->edition($workflow->enabled() ? $workflow->workingResource($r) : $r, $data);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, (int) $data['metadata_version'], ['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR)]);
return;
}
$revision = (int) $r->metadata_version + 1;
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
$resource = DB::table('portal_resources')->where('id', $id)->first();
@@ -165,6 +180,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$resource = $workflow->workingResource($resource);
}
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)])
->toResponse($request), static function (Response $response): void {
@@ -179,6 +199,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize($request->user(), $id);
$resource = $workflow->workingResource($resource);
}
$data = $this->validateContent($request);
abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
+153 -8
View File
@@ -3,34 +3,107 @@
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammes;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\LegacyContent;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use App\Services\SectorResourceReferences;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
{
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
$term = trim($input['q'] ?? '');
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
$original = trim($input['q'] ?? '');
$intent = $search->requestIntent($original);
if (isset($input['category']) && ($intent === null || $intent['type'] === 'products')) {
$intent = ['type' => 'products', 'label' => 'Produtos e localização', 'kind' => null,
'filter' => $intent['filter'] ?? $original,
'category' => match ($input['category']) {
'physical' => 'Produtos e localizações', 'financial' => 'Produtos financeiros', default => null,
}];
}
$term = $intent['filter'] ?? $original;
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== []) {
if ($tokens !== [] || $intent !== null) {
$factories = [
'Produtos e localizações' => function () use ($search, $tokens, $term) {
try {
return $this->productReferences($search, $tokens, $term);
} catch (\RuntimeException $exception) {
throw new HttpException(503, 'Referências de produtos temporariamente indisponíveis.', $exception);
}
},
'Produtos financeiros' => function () use ($search, $tokens, $term) {
$service = app(FundingProgrammes::class);
if (! $service->installed()) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = $service->catalogueQuery([])->select('p.id', 'v.payload')->selectRaw("v.payload->>'title' AS title");
foreach ($tokens as $token) {
$query->where(function ($match) use ($search, $token) {
$search->orFields($match, ["(v.payload->>'title')",
"(SELECT string_agg(product->>'title', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product)",
"(SELECT string_agg(place->>'location_text', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) place)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.locations[*].territory_path[*]') node)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.products[*].territory_paths[*][*]') node)"], $token);
foreach (FundingInstitutionDetails::NEEDS as $code => $label) {
$text = in_array($code, ['investment', 'working_capital'], true) ? 'Financiamento '.$label : $label;
if ($search->matches($text, [$token])) {
$match->orWhereJsonContains('v.payload->institution_services', $code)->orWhereJsonContains('v.payload->funding_needs', $code)
->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product WHERE product->'needs' @> ?::jsonb)", [json_encode([$code], JSON_THROW_ON_ERROR)]);
}
}
foreach (FundingInstitutionDetails::NATURES as $code => $label) {
if ($search->matches($label, [$token])) {
$match->orWhere('v.payload->institution_nature', $code);
}
}
});
}
return $this->source($query, $search, $term, $tokens, "(v.payload->>'title')", 'p.id', function ($row) {
$p = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
$products = array_values(array_unique(array_filter(array_column($p['products'] ?? [], 'title'))));
$services = array_values(array_intersect_key(FundingInstitutionDetails::NEEDS, array_flip($p['institution_services'] ?? [])));
$places = [];
foreach ($p['locations'] ?? [] as $location) {
if (! empty($location['location_text'])) {
$places[] = $location['location_text'];
}
foreach ($location['territory_path'] ?? [] as $node) {
if ($node['type'] !== 'country') {
$places[] = $node['name'];
}
}
}
$national = array_any($p['products'] ?? [], fn ($product) => ($product['coverage'] ?? '') === 'national');
return ['title' => $row->title, 'description' => implode(' · ', $products ?: $services) ?: 'Instituição financeira.',
'location' => $places === [] ? null : 'Presença física: '.implode(' · ', array_unique($places)),
'coverage' => $national ? 'Existe produto com cobertura nacional indicada; consulte as condições.' : 'Cobertura financeira: consultar cada produto.',
'url' => '/fundos/'.$row->id];
});
},
'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)
&& $search->score($page['title'], $term, $tokens) > 0));
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
@@ -64,9 +137,15 @@ class GlobalSearchController extends Controller
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
},
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term) {
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
if ($intent !== null && $intent['kind'] !== null) {
[$column, $table] = ['corridor' => ['corridor_id', 'development_corridors'], 'cluster' => ['cluster_id', 'economic_clusters'], 'project' => ['project_id', 'projects']][$intent['kind']];
$query->whereNotNull($column)->whereExists(fn ($entity) => $entity->selectRaw('1')->from($table)
->whereColumn($table.'.id', 'investment_dossiers.'.$column)->whereNull($table.'.deleted_at'));
// The existing unique entity key already prevents duplicate dossiers.
}
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
@@ -127,13 +206,75 @@ class GlobalSearchController extends Controller
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
if ($intent !== null) {
$categories = $intent['category'] === null ? ['Produtos e localizações', 'Produtos financeiros'] : [$intent['category']];
$factories = array_intersect_key($factories, array_flip($categories));
}
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]);
$answer = $intent === null ? null : ['type' => $intent['type'], 'label' => $intent['label'], 'filter' => $term,
'count' => $results['unavailable'] === [] ? $results['total'] : null];
if ($answer !== null && $intent['type'] === 'products') {
$answer['category'] = match ($intent['category']) {
'Produtos e localizações' => 'physical', 'Produtos financeiros' => 'financial', default => 'all',
};
}
return Inertia::render('portal/search', ['query' => $original, 'results' => $results, 'answer' => $answer]);
}
/** @param list<string> $tokens
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function productReferences(SearchTerms $search, array $tokens, string $term): array
{
$rows = [];
foreach (['mineracao', 'energia-especifica'] as $sector) {
if (! Sector::where('slug', $sector)->where('is_active', true)->whereNotNull('published_at')->exists()) {
continue;
}
$data = app(SectorResourceReferences::class)->view($sector);
foreach ($data['groups'] as $group) {
$locations = array_values(array_filter($group['location_labels'], fn ($name) => $search->matches($term, $search->tokens($name))));
$rows[] = ['title' => $group['name'], 'description' => $sector === 'mineracao' ? 'Referência mineral; não identifica uma concessão ou produção disponível.' : 'Segmento energético e cadeia de valor.',
'location' => $group['locations_text'], 'url' => '/sectores/'.$sector.'?'.http_build_query(['resource_group' => $group['id'],
...(count($locations) === 1 ? ['resource_location' => $locations[0]] : [])])];
}
}
if (app(LegacyContent::class)->available() && Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNotNull('published_at')->exists()) {
try {
app(LegacyContent::class)->article('agronegocio', false);
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
$published = Opportunity::published()->whereIn('management_creation_key', array_column($entries, 'key'))->pluck('slug', 'management_creation_key');
foreach ($entries as $entry) {
if (! isset($published[$entry['key']])) {
continue;
}
$rows[] = ['title' => $entry['chain'], 'description' => 'Cadeia agrícola: '.$entry['summary'], 'location' => 'Enquadramento territorial: '.$entry['corridor'],
'url' => '/oportunidades/'.rawurlencode($published[$entry['key']])];
}
} catch (HttpException $exception) {
if ($exception->getStatusCode() !== 404) {
throw $exception;
}
}
}
$rows = array_values(array_filter($rows, fn ($row) => $search->matches(implode(' ', $row), $tokens)));
usort($rows, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) ?: strcmp($a['title'], $b['title']));
return ['count' => count($rows), 'fetch' => fn ($offset, $limit) => array_slice($rows, $offset, $limit)];
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param callable(mixed):array<string,mixed> $present
* @param literal-string $title
* @param literal-string $id
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
@@ -143,10 +284,14 @@ class GlobalSearchController extends Controller
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
}
/** Public service entry points and terms used to find them. */
/** Public service entry points and terms used to find them.
* @return list<array{title:string,description:string,url:string}>
*/
private function pages(): array
{
return [
['title' => 'Licenciamento da empresa', 'description' => 'Roteiro de constituição e licenciamento conforme a actividade e localização.', 'url' => '/como-investir#planeamento'],
['title' => 'Preparar licenciamento no dossier', 'description' => 'Continuar a preparação na etapa de licenciamento prévio.', 'url' => '/investimentos/iniciar?step=S08'],
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
@@ -13,6 +13,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use App\Services\PortalEditorialWorkflow;
use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -99,6 +100,10 @@ class InvestmentPortfolioController extends Controller
{
$management = $request->routeIs('management.portfolio*');
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
$editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled();
if ($editorial) {
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($record);
}
$relatedIds = [];
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
@@ -147,7 +152,7 @@ class InvestmentPortfolioController extends Controller
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
return Inertia::render('portfolio/show', ['editorialEnabled' => $editorial, 'complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
@@ -167,6 +172,11 @@ class InvestmentPortfolioController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
'content.*' => ['nullable', 'string', 'max:15000']]);
if (app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id)) {
app(PortalEditorialWorkflow::class)->savePortfolio($request->user(), $dossier, (int) $data['version_no'], $data);
return back()->with('success', 'Edição privada guardada. A publicação anterior permanece disponível; continue no circuito editorial.');
}
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.');
@@ -179,6 +189,7 @@ class InvestmentPortfolioController extends Controller
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
{
abort_if(app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id), 409, 'Use a aprovação e publicação separadas da edição no circuito editorial.');
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
@@ -11,6 +11,8 @@ 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;
use Illuminate\Support\Facades\DB;
@@ -31,19 +33,35 @@ class InvestmentStructureController extends Controller
});
}
return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters,
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
$records = $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString();
if ($structure === 'clusters' && app(ClusterCompetitiveness::class)->editorialEnabled()) {
$records->through(function ($record) {
$analysis = app(ClusterCompetitiveness::class)->current($record->id);
$record->setAttribute('editorial_state', $analysis['workflow']['state'] ?? ($analysis['approved'] ? 'legacy_published' : 'not_started'));
return $record;
});
}
return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters, 'records' => $records]);
}
public function edit(Request $request, string $structure, ?string $id = null): Response
{
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id) : null;
if ($record && $editorialDossier) {
$record = app(PortalEditorialWorkflow::class)->workingStructure($record, $editorialDossier);
} 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', ['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')
@@ -56,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)) : [],
]);
}
@@ -67,11 +85,16 @@ class InvestmentStructureController extends Controller
$isCorridor = $structure === 'corridors';
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id, true) : null;
$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 || ($subjectWrites && $id)) {
$rules['editorial_version'] = ['required', 'integer', 'min:1'];
}
if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000'];
}
@@ -96,6 +119,22 @@ class InvestmentStructureController extends Controller
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
}
if ($editorialDossier) {
app(PortalEditorialWorkflow::class)->saveStructure($request->user(), $record, $editorialDossier, (int) $data['editorial_version'], $data);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Dados-base guardados numa edição privada da carteira. A publicação anterior está preservada.');
}
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.');
@@ -114,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'])],
@@ -121,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;
@@ -155,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.');
@@ -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();
@@ -2,8 +2,16 @@
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;
use Illuminate\Http\JsonResponse;
@@ -82,6 +90,18 @@ class InvestmentTerritoryController extends Controller
$table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
if ($dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id)) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$working = app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier);
$record->primary_territory_id = $working->getAttribute('primary_territory_id');
$record->territory_version = $working->getAttribute('territory_version');
} 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;
for ($i = 0; $current && $i < 5; $i++) {
@@ -93,7 +113,7 @@ class InvestmentTerritoryController extends Controller
$current = $location->parent_id;
}
return response()->json(['version' => $record->territory_version, 'path' => $path]);
return response()->json(['version' => $record->territory_version, 'path' => $path])->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, string $kind, string $id): JsonResponse
@@ -103,11 +123,29 @@ class InvestmentTerritoryController extends Controller
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
}
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$version = DB::transaction(function () use ($request, $table, $id, $data) {
$dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id, true);
$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);
abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$effectiveVersion = (int) $record->territory_version;
if ($dossier) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$effectiveVersion = (int) app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier)->getAttribute('territory_version');
} 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;
$names = [];
foreach (array_values($data['path']) as $index => $locationId) {
@@ -119,13 +157,26 @@ class InvestmentTerritoryController extends Controller
$names[] = $location->name;
}
$version = $data['version'] + 1;
if ($dossier) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveStructure($request->user(), $model::findOrFail($id), $dossier, (int) $workflow->workingPortfolio($dossier)->getAttribute('version_no'), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
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);
}
@@ -136,6 +187,8 @@ class InvestmentTerritoryController extends Controller
return $version;
});
return response()->json(['version' => $version]);
$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]);
}
}
@@ -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');
@@ -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();
@@ -0,0 +1,262 @@
<?php
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;
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<string,mixed> $data */
private function screen(Request $request, string $component, array $data): Response
{
$response = $request->expectsJson() ? response()->json($data) : Inertia::render($component, $data)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
public function index(Request $request, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$actor = $request->user();
$access = app(DocumentManagementAccess::class);
$query = $workflow->queue($actor);
$filters = array_filter($request->validate(['state' => 'nullable|in:draft,submitted,in_review,approved,published,returned,rejected,archived', 'kind' => 'nullable|in:article,legislation,document']), fn ($value) => $value !== null && $value !== '');
if (isset($filters['state'])) {
$query->where('e.state', $filters['state']);
}
if (isset($filters['kind'])) {
$query->whereRaw(self::KIND.' = ?', [$filters['kind']]);
}
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
$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,
'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);
}
public function show(Request $request, int $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latest($id);
$e = $selected ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $e, 404);
$payload = $e ? $workflow->payload($e) : null;
$independent = ! $e || ! $workflow->requiresIndependent($e) || ! in_array($request->user()->id, $payload['contributors'], true);
$data = ['resource' => ['id' => $id, 'title' => $resource->title, 'published' => (bool) $resource->published, 'public_edition_no' => $resource->published ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('state', 'published')->max('revision_no') : null], 'edition' => $e ? [...(array) $e, 'payload' => $payload, 'fresh' => $e->state === 'published' || $workflow->fresh($e)] : null,
'current' => $e && $e->id === $latest?->id,
'independentRequired' => $e ? $workflow->requiresIndependent($e) : false,
'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' => $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'])];
return $this->screen($request, 'management/editorial-edition', $data);
}
public function transition(Request $request, int $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all());
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function portfolio(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizePortfolio($request->user(), $id);
$record = DB::table('investment_dossiers')->where('id', $id)->first();
abort_unless($record !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestPortfolio($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'portfolio',
'resource' => ['id' => $id, 'title' => $record->title, 'published' => $record->publication_status === 'published', 'public_edition_no' => $record->publication_status === 'published' ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => true, 'canApprove' => $independent, '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->dossier_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function portfolioTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial da carteira registada.');
}
public function demography(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizeDemography($request->user(), $id);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestDemography($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! $workflow->requiresIndependent($edition) || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'demography',
'resource' => ['id' => $id, 'title' => $batch->file_name, 'published' => $batch->status === 'approved', 'public_edition_no' => $batch->status === 'approved' ? DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $edition ? $workflow->requiresIndependent($edition) : false, 'canApprove' => $independent, '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->demographic_import_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function demographyDraft(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$data = $request->validate(['expected_revision' => 'required|integer|min:0']);
$edition = $workflow->saveDemography($request->user(), $id, (int) $data['expected_revision']);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/demography/'.$id);
}
public function demographyTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial do lote registada.');
}
public function demographyOriginal(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizeDemography($request->user(), $id);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$path = Storage::disk('local')->path('demography/'.$id.'/original.xlsx');
abort_unless(CheckedFile::matches($path, trim($batch->sha256)), 409, 'Original indisponível ou alterado.');
return response()->download($path, basename($batch->file_name), ['Cache-Control' => 'private, no-store', 'X-Robots-Tag' => 'noindex, nofollow']);
}
public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$e = DB::table('portal_editorial_editions')->where('id', $edition)->where('resource_id', $id)->first();
abort_unless($e !== null, 404);
$file = null;
foreach ($workflow->payload($e)['files'] as $entry) {
if ($entry['id'] === $document) {
$file = $entry;
break;
}
}
abort_unless($file !== null, 404);
abort_unless($workflow->canReadFile($request->user(), $document), 403);
$record = DB::table('documents')->where('id', $document)->first();
abort_unless($record && hash_equals(trim($file['sha256']), trim($record->sha256)) && (int) $file['version_no'] === (int) $record->version_no, 409, 'O anexo mudou; esta edição conserva a referência anterior.');
return $this->binary($record);
}
private function binary(\stdClass $record): Response
{
$response = app(VerifiedDocumentBinary::class)->respond($record);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
return $response;
}
public function markRead(Request $request, int $notice, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$n = DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->first();
abort_unless($n !== null, 404);
abort_unless($workflow->visibleNotice($request->user(), $n), 404);
DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back();
}
}
@@ -15,6 +15,7 @@ use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\PublicSearchMetrics;
@@ -320,6 +321,9 @@ class PortalServicesController extends Controller
$editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) {
@@ -328,7 +332,7 @@ class PortalServicesController extends Controller
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
@@ -353,12 +357,15 @@ class PortalServicesController extends Controller
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$malwareHealth = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
@@ -474,9 +481,15 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, 1, ['_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]]);
return $id;
}
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
@@ -496,7 +509,9 @@ class PortalServicesController extends Controller
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$workflow = app(PortalEditorialWorkflow::class);
$current = $workflow->enabled() ? $workflow->workingResource($resource) : $resource;
abort_if((int) $current->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
@@ -507,6 +522,12 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, $expectedVersion, [...$fields, '_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]], $data['associations'] ?? []);
return;
}
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
@@ -523,7 +544,7 @@ class PortalServicesController extends Controller
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
return to_route('services.manage')->with('success', 'Edição guardada; confira o circuito editorial quando estiver activo. A versão pública é preservada nesse circuito.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
@@ -537,6 +558,12 @@ class PortalServicesController extends Controller
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
if ($type === 'resources' && app(PortalEditorialWorkflow::class)->enabled()) {
app(PortalEditorialWorkflow::class)->authorize($request->user(), $id, true);
}
if ($type === 'resources' && $data['published'] && app(PortalEditorialWorkflow::class)->enabled()) {
abort(409, 'Use a aprovação e publicação explícitas da edição no circuito editorial.');
}
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
@@ -582,6 +609,7 @@ class PortalServicesController extends Controller
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{
abort_if(app(PortalEditorialWorkflow::class)->enabled(), 409, 'A divulgação de novas edições requer aprovação e publicação no circuito editorial.');
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first();
@@ -5,11 +5,32 @@ namespace App\Http\Controllers;
use App\Services\TerritorialRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class TerritorialRegistryController extends Controller
{
public function editor(Request $request, TerritorialRegistry $registry): Response
{
abort_unless($registry->editorialAvailable(), 404);
$actor = $request->user();
$roles = array_values(array_filter(['editors', 'reviewers', 'publishers'], fn ($role) => in_array(strtolower($actor->email), config('territorial.'.$role, []), true)));
$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
{
abort_unless($registry->editorialAvailable(), 404);
return response()->json($registry->preview($id));
}
public function sources(Request $request): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
@@ -17,13 +38,25 @@ class TerritorialRegistryController extends Controller
return response()->json(DB::table('geo_sources')->orderBy('id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
}
public function drafts(Request $request): JsonResponse
public function drafts(Request $request, TerritorialRegistry $registry): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
return response()->json(DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
$page = DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
->select(['g.id', 'g.name', 'g.code', 'g.location_type', 'g.parent_id', 'g.version_no', 'p.source_id', 'p.status', 'p.valid_from', 'p.valid_to', 'p.editor_id', 'p.reviewer_id', 'p.publisher_id'])
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString();
if ($registry->editorialAvailable()) {
abort_unless($page instanceof CursorPaginator, 503, 'Paginação territorial indisponível.');
$page->through(function ($row) use ($registry) {
$state = $registry->working($row->id);
return [...$state['location'], ...$state['profile'], 'id' => $row->id,
'public_version_no' => $state['public_version_no'], 'fresh' => $state['fresh'],
'private_revision' => $state['_editorial']['private'] ?? false];
});
}
return response()->json($page);
}
public function index(Request $request, TerritorialRegistry $registry, ?string $collection = null): JsonResponse
@@ -74,7 +107,8 @@ class TerritorialRegistryController extends Controller
{
$id = $registry->save($request->user(), $request->all(), $id);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => DB::table('geo_locations')->where('id', $id)->value('version_no')]);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => $registry->editorialAvailable()
? $registry->working($id)['location']['version_no'] : DB::table('geo_locations')->where('id', $id)->value('version_no')]);
}
public function source(Request $request): JsonResponse
@@ -95,9 +129,15 @@ class TerritorialRegistryController extends Controller
public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
$data = $request->validate(['action' => ['required', 'in:pending_review,approved,published,rejected,archived'],
$data = $request->validate(['action' => ['required', 'in:pending_review,in_review,approved,published,rejected,archived,withdrawn'],
'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000']]);
$registry->ensureWriteMode($id);
if ($registry->editorialAvailable() && $registry->hasEditorialHistory($id)) {
return response()->json($registry->transitionEditorial($request->user(), $id, $data));
}
abort_if(in_array($data['action'], ['in_review', 'withdrawn'], true), 422);
return DB::transaction(function () use ($request, $registry, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
@@ -57,6 +57,8 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'administrationNavigation' => app(ContentManagement::class)->administrationNavigation($request->user(), $contentManagementAccess),
'dashboardNavigation' => app(ContentManagement::class)->dashboardNavigation($request->user(), $contentManagementAccess),
'procurementEnabled' => app(Procurement::class)->available(),
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
@@ -24,9 +26,16 @@ class TerritorialRequest
} else {
abort_unless($request->user()->can('manage-portal') && $request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
$role = $request->is('*/transitions') ? match ($request->input('action')) {
'approved', 'rejected' => 'reviewers', 'published', 'archived', 'revoked' => 'publishers', default => 'editors',
'in_review', 'approved', 'rejected' => 'reviewers', 'published', 'archived', 'withdrawn', 'revoked' => 'publishers', default => 'editors',
} : 'editors';
abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.');
if ($request->is('api/v1/geo/territories*')) {
app(TerritorialRegistry::class)->ensureWriteMode($request->route('id'));
if (config('editorial_workflow.territorial_versions')) {
abort_unless(app(PortalProfileAccess::class)->mfa($request->user())
&& ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403, 'MFA válido necessário.');
}
}
$key = $request->header('Idempotency-Key', '');
abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.');
$scope = $request->method().':'.$request->path();
+116 -5
View File
@@ -3,10 +3,13 @@
namespace App\Services;
use App\Models\EconomicCluster;
use App\Models\User;
use Illuminate\Database\Query\Builder;
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
@@ -46,6 +49,12 @@ final class ClusterCompetitiveness
public function management(string $cluster): array
{
$state = $this->current($cluster);
$state['editorial_enabled'] = $this->editorialEnabled();
if (isset($state['workflow'])) {
$record = EconomicCluster::findOrFail($cluster);
$state['workflow']['fresh'] = hash_equals($state['workflow']['hash'], $this->editionHash($record, $state['draft']));
$state['workflow']['independent_required'] = $this->independentRequired($state['workflow']);
}
$state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get()
->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all();
@@ -106,12 +115,17 @@ final class ClusterCompetitiveness
*/
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
{
abort_if(! in_array($action, ['saved', 'restored', 'approved', 'withdrawn'], true) && ! $this->editorialEnabled(), 503, 'Circuito editorial dos clusters ainda não activado.');
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$cluster = EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$before = $this->current($cluster->id);
abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.');
$state = $before;
if ($action === 'saved') {
if ($this->editorialEnabled() || isset($before['workflow'])) {
abort_unless($this->editorialEnabled(), 503, 'Reactive o circuito editorial para continuar esta análise; o histórico e a publicação foram conservados.');
$state = $this->editorialChange($cluster, $actor, $action, $input, $before);
} elseif ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
@@ -127,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);
});
@@ -144,9 +163,101 @@ final class ClusterCompetitiveness
*/
public function publicData(string $cluster): ?array
{
$approved = $this->current($cluster)['approved'];
$state = $this->current($cluster);
$approved = isset($state['workflow']) ? $state['published'] : $state['approved'];
// Explicit projection: never return editor identity, approval evidence, history or the unapproved draft.
return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
}
public function editorialEnabled(): bool
{
return (bool) config('editorial_workflow.cluster_competitiveness');
}
/** @param array<array-key,mixed>|null $draft */
private function editionHash(EconomicCluster $cluster, ?array $draft): string
{
$context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']);
return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR));
}
/** @param array<array-key,mixed> $input
* @param array<array-key,mixed> $state
* @return array<array-key,mixed>
*/
private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array
{
$actor = User::whereKey($actorId)->firstOrFail();
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
if (! isset($state['workflow'])) {
abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.');
// An accepted legacy publication remains public; no retrospective approval is invented.
$state['published'] = $state['approved'];
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null,
'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false];
}
$workflow = $state['workflow'];
if (in_array($action, ['saved', 'restored'], true)) {
if ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
$restoredContributors = [];
} else {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event !== null, 404);
$restored = $this->decode($event);
$state['draft'] = $restored['draft'];
$restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id];
}
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1,
'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])),
'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null,
'independent_required' => $this->independentRequired($workflow)];
return $state;
}
validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate();
$allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'],
'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.');
if (in_array($action, ['submitted', 'approved', 'published'], true)) {
abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.');
}
if ($action === 'submitted' || $action === 'approved') {
$this->assertComplete($state['draft']);
}
if ($action === 'approved') {
abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.');
$workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']];
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']];
}
if ($action === 'published') {
$decision = $workflow['approval'];
abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.');
$reviewer = User::whereKey($decision['actor'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.');
abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.');
$state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1];
}
if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) {
$state['published'] = null;
}
$workflow['state'] = match ($action) {
'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected',
'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'],
};
$state['workflow'] = $workflow;
return $state;
}
/** @param array<array-key,mixed> $workflow */
private function independentRequired(array $workflow): bool
{
return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true);
}
}
+74
View File
@@ -24,6 +24,15 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
if (app(PortalEditorialWorkflow::class)->demographyAccess($actor)) {
$result['demography'] = ['index' => '/management/demography'];
}
if (app(PortalEditorialWorkflow::class)->portfolioAvailable() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
$result['portfolio'] = ['index' => '/management/portfolio'];
}
if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
$result['clusters'] = ['index' => '/management/clusters'];
}
if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) {
$result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
}
@@ -35,9 +44,74 @@ final class ContentManagement
$result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
}
if (($editor || isset($result['opportunities'])) && ! $profiles->has($actor, 'auditor') && app(PortalEditorialWorkflow::class)->available()) {
$result['editorial'] = ['index' => '/management/editorial'];
}
return $result;
}
/** @param array<array-key,mixed> $access
* @return list<array{label:string,href:string}>
*/
public function administrationNavigation(?User $actor, array $access): array
{
if (! $actor || ! $actor->hasVerifiedEmail()) {
return [];
}
$items = [];
foreach (['documents' => 'Biblioteca e documentos', 'opportunities' => 'Oportunidades', 'clusters' => 'Competitividade', 'portfolio' => 'Carteira', 'demography' => 'Demografia', 'procurement' => 'Concursos e anúncios'] as $key => $label) {
if (isset($access[$key]['index'])) {
$items[] = ['label' => $label, 'href' => $access[$key]['index']];
}
}
if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')) {
$items[] = ['label' => 'Estruturas e relações', 'href' => '/management/editorial/subjects'];
}
$native = app(PortalEditorialNativeNotices::class);
if ($native->access($actor, 'territorial-editorial-v1')) {
$items[] = ['label' => 'Conteúdos territoriais', 'href' => '/management/territorial/editorial'];
}
if ($native->access($actor, 'investment-zones')) {
$items[] = ['label' => 'Zonas e indicadores', 'href' => '/management/investment/zones'];
}
return $items;
}
/** @param array<array-key,mixed> $access
* @return list<array{label:string,href:string}>
*/
public function dashboardNavigation(?User $actor, array $access): array
{
if (! $actor || ! $actor->hasVerifiedEmail() || (! $actor->can('manage-portal') && $access === [] && ! app(ScopedPortalAdministration::class)->administrative($actor))) {
return [];
}
$items = [['label' => 'Visão geral', 'href' => '/dashboard'], ['label' => 'O meu trabalho', 'href' => '/dashboard#meu-trabalho']];
if ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')) {
foreach (['manifestacoes' => 'Manifestações de interesse', 'indicadores-oportunidades' => 'Indicadores de oportunidades', 'pedidos-recentes' => 'Pedidos recentes', 'accoes-rapidas' => 'Acções rápidas'] as $anchor => $label) {
$items[] = ['label' => $label, 'href' => '/dashboard#'.$anchor];
}
$items[] = ['label' => 'Indicadores de utilização', 'href' => '/management/product-usage'];
}
if (! $actor->can('manage-portal') && app(InvestorPath::class)->available()) {
$items[] = ['label' => 'Os meus investimentos', 'href' => '/dashboard#meus-investimentos'];
}
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->available() && $workflow->canEnter($actor)) {
$items[] = ['label' => 'Circuito editorial', 'href' => '/management/editorial'];
$documents = app(DocumentManagementAccess::class);
if ($documents->full($actor) || $documents->editorial($actor)) {
foreach (['article' => 'Revisão de artigos', 'legislation' => 'Revisão de legislação', 'document' => 'Revisão de documentos'] as $kind => $label) {
$items[] = ['label' => $label, 'href' => '/management/editorial?kind='.$kind];
}
}
$items[] = ['label' => 'Notificações editoriais', 'href' => '/management/editorial#notificacoes'];
}
return $items;
}
/**
* @return array<array-key, mixed>
*/
+32 -9
View File
@@ -7,6 +7,7 @@ use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
@@ -15,26 +16,37 @@ final class DemographicImport
{
/**
* CLI preparation is read-only input, never an import or approval.
*
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
if (! is_string($root) || $root === '') { return null; }
if (! is_string($root) || $root === '') {
return null;
}
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
if (! is_file($directory.'/preparation.json')) { return null; }
if (! is_file($directory.'/preparation.json')) {
return null;
}
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
try { $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Recibo CLI inválido.'); }
try {
$receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
abort(409, 'Recibo CLI inválido.');
}
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
try { $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Prévia CLI inválida.'); }
try {
$report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
abort(409, 'Prévia CLI inválida.');
}
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
@@ -49,10 +61,15 @@ final class DemographicImport
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) { return false; }
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) {
return false;
}
}
return $files !== [];
} catch (\Throwable) { return false; }
} catch (\Throwable) {
return false;
}
}
public function authorize(User $actor): void
@@ -164,6 +181,7 @@ final class DemographicImport
private function literal(string $name): string
{
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
}
@@ -228,13 +246,16 @@ final class DemographicImport
{
if (! isset($report['records_file_sha256'])) {
yield from $report['records'];
return;
}
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
$stream = new \SplFileObject($path, 'r');
while (! $stream->eof()) {
$line = $stream->fgets();
if (trim($line) === '') { continue; }
if (trim($line) === '') {
continue;
}
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
}
@@ -246,6 +267,8 @@ final class DemographicImport
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch !== null, 404);
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
abort_if($action === 'approved' && $batch->status !== 'approved' && app(PortalEditorialWorkflow::class)->demographyWrites($id), 409, 'Use a aprovação e publicação separadas do lote no circuito editorial.');
abort_if($action === 'disabled' && Schema::hasColumn('portal_editorial_editions', 'demographic_import_id') && DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->exists(), 409, 'Arquive a edição no circuito editorial para retirar a sua publicação.');
// No deletes, replacement of other editions, or automatic publication in production.
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
+12 -3
View File
@@ -3,11 +3,14 @@
namespace App\Services;
use Illuminate\Database\QueryException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch
{
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable<array<string,mixed>>}> $factories
* @return array{data:list<array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,unavailable:list<string>}
*/
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{
$sources = [];
@@ -15,7 +18,10 @@ final class FederatedSearch
foreach ($factories as $label => $factory) {
try {
$sources[$label] = $factory();
} catch (QueryException $exception) {
} catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception);
$unavailable[] = $label;
}
@@ -45,7 +51,10 @@ final class FederatedSearch
if ($batch === []) {
$positions[$label] = $source['count'];
}
} catch (QueryException $exception) {
} catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception);
$unavailable[] = $label;
unset($sources[$label]);
+1
View File
@@ -58,6 +58,7 @@ final class InvestmentMapIndicators
'zone_id' => $zone->id, 'zone_name' => $zone->name, 'zone_version' => (int) $zone->version_no, 'url' => '/zonas/'.$zone->id,
'theme' => $row['indicator'] === 'employment' ? 'workforce' : 'economic_potential',
'value' => $value, 'knowledge' => $knowledge, 'missing_metadata' => $missing,
'measure_kind' => in_array($row['measure_kind'] ?? null, ['observed', 'planned', 'projected'], true) ? $row['measure_kind'] : 'unknown',
];
}
}
@@ -18,7 +18,7 @@ class InvestmentZoneIntegralDetails
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'],
'occupants' => ['organization_id', 'documentary_name', 'phase_id', 'plot_id', 'status', 'valid_from', 'valid_until'],
'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'observations' => ['indicator', 'measure_kind', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'],
];
@@ -59,6 +59,7 @@ class InvestmentZoneIntegralDetails
$rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$rules['observations.*.measure_kind'] = 'nullable|in:unknown,observed,planned,projected';
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $group) {
@@ -148,7 +149,7 @@ class InvestmentZoneIntegralDetails
foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) {
continue;
}$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
}$key = implode('|', [$item['indicator'], $item['measure_kind'] ?? 'unknown', $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');
$keys[] = $key;
}
@@ -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<array-key,mixed> $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<array-key,mixed> $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<array-key,mixed> $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<array-key,mixed> $draft
* @param array<array-key,mixed>|null $previous
* @return array<array-key,mixed>
*/
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<array-key,mixed> */
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<array-key, mixed>|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<array-key,mixed>|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<array-key,mixed> $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'] ?? []);
}
}
}
+28 -10
View File
@@ -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<array-key,mixed>|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,
@@ -308,6 +308,9 @@ class OpportunityCanonicalContract
app(OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256);
if (data_get($record->workflow_context, 'editorial_version_binding') === 'phase1-exact-v1') {
$row->file_version_no = (int) $file->version_no;
}
}
} else {
$location = $row->payload;
+9 -2
View File
@@ -178,6 +178,11 @@ class OpportunityProfileWorkflow
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.');
$this->authorizeAction($actor, $record, $action);
$context = $record->workflow_context ?? [];
if ($action === 'submit' && app(PortalEditorialWorkflow::class)->available()) {
$context['editorial_version_binding'] = 'phase1-exact-v1';
$record->workflow_context = $context;
}
$strictEditorial = ($context['editorial_version_binding'] ?? null) === 'phase1-exact-v1';
$state = $this->access->state($record);
$before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version];
$from = match ($action) {
@@ -234,7 +239,7 @@ class OpportunityProfileWorkflow
}
}
$baseline = $context['review_baseline'] ?? $context['submission'] ?? [];
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']));
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']), $strictEditorial);
$context['materiality'] = array_diff_key($materiality, ['reviews' => true]);
$context['reviews'] = $materiality['reviews'];
app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']);
@@ -290,7 +295,7 @@ class OpportunityProfileWorkflow
foreach ($this->contract->gates($record) as $gate) {
$review = $context['reviews'][$gate] ?? [];
abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review), 409, 'Âmbito da revisão alterado.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão da revisão alterado.');
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
@@ -316,6 +321,7 @@ class OpportunityProfileWorkflow
foreach ($this->contract->gates($record) as $gate) {
$review = $context['approval']['gates'][$gate] ?? [];
abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Revisão aprovada desta versão necessária.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão jurídica alterados após a aprovação.');
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
@@ -476,6 +482,7 @@ class OpportunityProfileWorkflow
$record->management_version++;
$record->workflow_context = $context;
$record->save();
app(PortalEditorialWorkflow::class)->notifyOpportunity($actor, $record, $action);
$this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before,
'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason']]);
@@ -134,8 +134,12 @@ class OpportunityReviewMateriality
/** @param array<string,mixed> $snapshot
* @param array<string,mixed> $review */
public function matches(array $snapshot, string $gate, array $review): bool
public function matches(array $snapshot, string $gate, array $review, bool $exactLegalVersion = false): bool
{
if ($exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1
&& ($review['hash'] ?? null) !== ($snapshot['hash'] ?? null)) {
return false;
}
if (! isset($review['scope_hash'])) {
return true; // Caller must independently verify the original signed submission hash.
}
@@ -151,7 +155,7 @@ class OpportunityReviewMateriality
* @param array<string,mixed> $reviews
* @param list<string> $gates
* @return array<string,mixed> */
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false): array
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false, bool $exactLegalVersion = false): array
{
$kept = [];
$reopened = [];
@@ -161,7 +165,8 @@ class OpportunityReviewMateriality
// separately validate old scope signatures under their original rule.
$priorHash = $this->hash($before, $gate, $review);
$currentHash = $this->hash($after, $gate, $review);
if (! $force && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
$changedLegalVersion = $exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 && ($review['hash'] ?? null) !== ($after['hash'] ?? null);
if (! $force && ! $changedLegalVersion && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
$kept[$gate] = [...$review, 'scope_hash' => $priorHash, 'scope_rule' => self::VERSION, 'accepted_submission_hash' => $after['hash'],
'carried_forward' => ['rule' => self::VERSION, 'from_scope_hash' => $review['scope_hash'] ?? null, 'from_scope_rule' => $review['scope_rule'] ?? 'legacy_unspecified', 'from_submission_hash' => $before['hash'] ?? null, 'at' => now()->toIso8601String()]];
} else {
@@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Adapts existing immutable native revisions to the common inbox; never changes a workflow. */
final class PortalEditorialNativeNotices
{
public function available(): bool
{
return app(PortalEditorialWorkflow::class)->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<int> $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<string,mixed> */
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.'&notice_version='.$version,
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
}
}
+394
View File
@@ -0,0 +1,394 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Subject mapping and publication effects for the existing common editorial engine. */
final class PortalEditorialSubjects
{
public const SUBJECTS = [
'projects' => ['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<string>} */
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<string,mixed> $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<string,string> */
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<string,list<string>> */
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<array<string,mixed>> */
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<string,mixed> $input
* @return array<string,mixed>
*/
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<array{id:string,name:string}> */
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<string,mixed> $fields
* @return array<string,mixed>
*/
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<string,mixed> $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<string,mixed> $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)]);
}
}
+989
View File
@@ -0,0 +1,989 @@
<?php
namespace App\Services;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Private immutable content editions; existing resources remain the public projection.
* @phpstan-type EditorialPayload array{resource:array<string,mixed>,links:list<array<string,mixed>>,files:list<array<string,mixed>>,document_changes:array<string,mixed>,contributors:list<int>,policy:array{independent_required:bool}}
*/
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<string,mixed> $fields
* @param list<int> $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<string,mixed> $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'],
'development_corridors' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version'],
];
public function available(): bool
{
return (bool) config('editorial_workflow.enabled') && Schema::hasTable('portal_editorial_editions') && Schema::hasColumn('portal_editorial_notifications', 'opportunity_id');
}
public function enabled(): bool
{
if (! config('editorial_workflow.enabled')) {
return false;
}
abort_unless($this->available(), 503, 'Estrutura editorial ainda não instalada.');
return true;
}
public function authorize(User $actor, int $resource, bool $write = false): void
{
abort_unless($this->enabled(), 503, 'Circuito editorial ainda não instalado/activado neste ambiente.');
abort_unless($actor->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
app(DocumentManagementAccess::class)->assertEditableResource($actor, $resource);
if ($edition = $this->latest($resource)) {
foreach ($this->payload($edition)['files'] as $file) {
abort_unless($this->canReadFile($actor, $file['id']), 403);
}
}
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function canReadFile(User $actor, string $id): bool
{
return app(DocumentManagementAccess::class)->full($actor) || app(DocumentManagementAccess::class)->documents($actor)->where('id', $id)->exists()
|| app(EditorialReading::class)->publicResources()->where('d.id', $id)->exists();
}
public function fresh(\stdClass $e): bool
{
$p = $this->payload($e);
try {
return hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $p)));
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [404, 409, 422], true)) {
return false;
}
throw $exception;
}
}
public function latest(int $resource): ?\stdClass
{
return $this->enabled() ? DB::table('portal_editorial_editions')->where('resource_id', $resource)->orderByDesc('revision_no')->first() : null;
}
/** @return EditorialPayload */
public function payload(\stdClass $edition): array
{
return json_decode($edition->payload, true, 512, JSON_THROW_ON_ERROR);
}
public function workingResource(\stdClass $resource): \stdClass
{
$e = $this->latest((int) $resource->id);
return $e ? (object) [...(array) $resource, ...$this->payload($e)['resource'], 'metadata_version' => max((int) $resource->metadata_version, (int) $e->revision_no), 'published' => $resource->published] : $resource;
}
public function queue(User $actor): Builder
{
abort_unless($this->enabled(), 503);
$access = app(DocumentManagementAccess::class);
abort_unless($this->canEnter($actor), 403);
$query = DB::table('portal_editorial_editions as e')->join('portal_resources as r', 'r.id', '=', 'e.resource_id')->leftJoin('users as u', 'u.id', '=', 'e.assigned_to')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.resource_id', 'e.resource_id')->whereColumn('later.revision_no', '>', 'e.revision_no'));
if (! ($access->full($actor) || $access->editorial($actor))) {
$query->whereRaw('false');
} elseif (! $access->full($actor)) {
$query->whereIn('r.id', $access->editorialResources($actor)->select('r.id'));
}
return $query;
}
public function canEnter(User $actor): bool
{
if (! $actor->hasVerifiedEmail() || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
$access = app(DocumentManagementAccess::class);
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']);
}
public function opportunityPending(User $actor): int
{
$profiles = app(PortalProfileAccess::class);
if (! $profiles->available()) {
return 0;
}
return $profiles->scope(Opportunity::query(), $actor)->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('draft','submitted','in_review','information_pending','needs_information','approved')")->count();
}
public function notifyOpportunity(User $actor, Opportunity $record, string $action): void
{
if (! $this->available()) {
return;
}
$context = $record->workflow_context ?? [];
$recipients = array_unique(array_filter([$record->owner_user_id, $context['assignment']['assignee_user_id'] ?? null, $context['publication_assignment']['assignee_user_id'] ?? null]));
foreach ($recipients as $recipient) {
if ((int) $recipient === $actor->id) {
continue;
}
DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient, 'edition_id' => null, 'opportunity_id' => $record->id, 'opportunity_version' => $record->management_version,
'content_hash' => $context['submission']['hash'] ?? $context['approval']['hash'] ?? null, 'action' => $action, 'created_at' => now()]);
}
}
public function visibleNotice(User $actor, \stdClass $notice): bool
{
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();
return $record !== null && app(PortalProfileAccess::class)->canView($actor, $record);
}
$edition = DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first();
if (! $edition) {
return false;
}
try {
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;
}
$this->authorizeDemography($actor, $edition->demographic_import_id);
} elseif ($edition->dossier_id ?? null) {
if (! config('editorial_workflow.portfolio')) {
return false;
}
$this->authorizePortfolio($actor, $edition->dossier_id);
} else {
$this->authorize($actor, (int) $edition->resource_id);
}
return true;
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [403, 404], true)) {
return false;
} throw $exception;
}
}
/** @param array<string,mixed> $resource */
private function independentPolicy(int $id, array $resource): bool
{
$kind = ($resource['legislation'] ?? false) || ($resource['document_type'] ?? '') === 'legislation' ? 'legislation' : (! empty($resource['editorial_content']) ? 'article' : 'document');
return in_array($id, config('editorial_workflow.independent_approval.resources', []), true) || in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true);
}
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);
}
return $p['policy']['independent_required'] || $this->independentPolicy((int) $edition->resource_id, $p['resource']);
}
/** @return list<array<string,mixed>> */
public function notices(User $actor): array
{
if (! $this->available()) {
return [];
}
abort_unless($this->canEnter($actor), 403);
$result = [];
foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->lazyByIdDesc(100) as $notice) {
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' => $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;
}
}
return $result;
}
public function unreadCount(User $actor): int
{
$count = 0;
foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->whereNull('read_at')->lazyByIdDesc(100) as $notice) {
if ($this->visibleNotice($actor, $notice)) {
$count++;
}
}
return $count;
}
/** @return array<string,int>|null */
public function dashboard(User $actor): ?array
{
$access = app(DocumentManagementAccess::class);
if (! $this->available() || ! $this->canEnter($actor)) {
return null;
}
$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<string,mixed> $resource
* @param list<array<string,mixed>> $links
* @param array<string,mixed> $documentChanges
* @param list<int> $contributors
* @param array{independent_required:bool} $policy
* @return EditorialPayload
*/
private function snapshot(array $resource, array $links, array $documentChanges = [], array $contributors = [], array $policy = ['independent_required' => false]): array
{
$ids = [(string) $resource['document_id']];
$body = is_string($resource['editorial_content'] ?? null) ? json_decode($resource['editorial_content'], true, 512, JSON_THROW_ON_ERROR) : ($resource['editorial_content'] ?? null);
if (! empty($body['image_resource_id'])) {
$image = DB::table('portal_resources')->where('id', $body['image_resource_id'])->first();
abort_unless($image && app(EditorialReading::class)->publicResources()->where('r.id', $image->id)->exists(), 422, 'Imagem pública indisponível.');
$ids[] = (string) $image->document_id;
}
foreach ($links as $link) {
if ($link['target_type'] === 'document') {
$id = DB::table('portal_resources')->where('id', $link['target_key'])->value('document_id');
abort_unless($id !== null, 422, 'Anexo documental inexistente.');
$ids[] = (string) $id;
}
}
$files = array_values(DB::table('documents')->whereIn('id', array_unique($ids))->orderBy('id')->get()->map(function ($document) {
if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) {
app(VerifiedDocumentBinary::class)->respond($document);
}
return Arr::only(get_object_vars($document), ['id', 'file_name', 'media_type', 'size_bytes', 'sha256', 'version_no', 'status', 'deleted_at', 'issuer', 'issued_on', 'rights_status', 'data_classification', 'malware_scan_status', 'malware_scanned_sha256']);
})->all());
abort_unless(count($files) === count(array_unique($ids)), 422, 'Documento não encontrado.');
return ['resource' => $resource, 'links' => $links, 'files' => $files, 'document_changes' => $documentChanges, 'contributors' => $contributors, 'policy' => $policy];
}
/** @param array<string,mixed> $payload */
private function hash(array $payload): string
{
return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR));
}
public function portfolioEnabled(): bool
{
if (! config('editorial_workflow.portfolio')) {
return false;
}
abort_unless($this->portfolioAvailable(), 503, 'Estrutura editorial da carteira ainda não instalada.');
return true;
}
public function portfolioAvailable(): bool
{
return (bool) config('editorial_workflow.portfolio') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'dossier_id');
}
public function portfolioPending(User $actor): int
{
if (! $this->portfolioAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return 0;
}
return DB::table('portal_editorial_editions as e')->join('investment_dossiers as d', 'd.id', '=', 'e.dossier_id')->whereNull('d.deleted_at')
->whereNotIn('e.state', ['published', 'archived'])->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.dossier_id', 'e.dossier_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count();
}
public function portfolioWrites(string $id): bool
{
if ($this->portfolioEnabled()) {
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'dossier_id') && DB::table('portal_editorial_editions')->where('dossier_id', $id)->exists(), 503, 'Reactive o circuito editorial para editar esta ficha; a publicação anterior está preservada.');
return false;
}
public function authorizePortfolio(User $actor, string $id, bool $write = false): void
{
abort_unless($this->portfolioEnabled(), 503);
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless(DB::table('investment_dossiers')->where('id', $id)->whereNull('deleted_at')->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function latestPortfolio(string $id): ?\stdClass
{
return DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->first();
}
public function workingPortfolio(InvestmentDossier $record): InvestmentDossier
{
$edition = $this->latestPortfolio($record->id);
if (! $edition) {
return $record;
}
$copy = clone $record;
$fields = $this->payload($edition)['resource'];
$copy->forceFill(Arr::only($fields, ['title', 'source', 'content']));
$copy->setAttribute('version_no', max((int) $record->getAttribute('version_no'), (int) $edition->revision_no));
return $copy;
}
public function structureDossier(string $structure, string $id, bool $write = false): ?InvestmentDossier
{
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$structure] ?? null;
if (! $foreign) {
return null;
}
$dossier = ($write ? InvestmentDossier::withTrashed() : InvestmentDossier::query())->where($foreign, $id)->first();
if (! $dossier) {
return null;
}
$enabled = $write ? $this->portfolioWrites($dossier->id) : $this->portfolioAvailable();
abort_if($enabled && $write && $dossier->trashed(), 409, 'A ficha associada está arquivada. Recupere o contexto antes de alterar os dados-base.');
return $enabled ? $dossier : null;
}
public function workingStructure(Model $record, InvestmentDossier $dossier): Model
{
$copy = clone $record;
$edition = $this->latestPortfolio($dossier->id);
if ($edition && $edition->published_at === null) {
$intent = $this->payload($edition)['resource']['_structure'] ?? null;
if ($intent) {
abort_unless($intent['id'] === $record->getKey() && $intent['table'] === $record->getTable(), 409);
$copy->forceFill($intent['fields']);
}
}
$copy->setAttribute('editorial_version', (int) $this->workingPortfolio($dossier)->getAttribute('version_no'));
return $copy;
}
/** @param array<string,mixed> $changes */
public function saveStructure(User $actor, Model $record, InvestmentDossier $dossier, int $expected, array $changes): \stdClass
{
$this->authorizePortfolio($actor, $dossier->id, true);
return DB::transaction(function () use ($actor, $record, $dossier, $expected, $changes) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
$edition = $this->latestPortfolio($dossier->id);
$prior = $edition && $edition->published_at === null ? ($this->payload($edition)['resource']['_structure']['fields'] ?? []) : [];
$allowed = self::STRUCTURE_FIELDS[$record->getTable()] ?? abort(422);
return $this->savePortfolio($actor, $dossier, $expected, ['_structure' => ['table' => $record->getTable(), 'id' => $record->getKey(), 'fields' => array_replace($prior, Arr::only($changes, $allowed))]]);
});
}
/** @param array<string,mixed> $fields
* @param list<int> $contributors
* @return EditorialPayload
*/
private function portfolioSnapshot(array $fields, array $contributors): array
{
if (is_string($fields['content'])) {
$fields['content'] = json_decode($fields['content'], true, 512, JSON_THROW_ON_ERROR);
}
$links = [];
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $key => $table) {
if (! empty($fields[$key])) {
$context = DB::table($table)->where('id', $fields[$key])->whereNull('deleted_at')->first();
abort_unless($context !== null, 422, 'Contexto da ficha indisponível.');
$links[] = ['target_type' => $table, 'target_key' => $fields[$key], 'context' => Arr::only(get_object_vars($context), ['id', 'name', 'code', 'sector', 'status', 'description', 'primary_territory_id', 'territory_version', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])];
}
}
abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.');
if ($intent = $fields['_structure'] ?? null) {
abort_unless($intent['id'] === $links[0]['target_key'] && $intent['table'] === $links[0]['target_type'], 422, 'Alteração de dados-base fora do contexto da ficha.');
abort_unless(array_diff(array_keys($intent['fields']), self::STRUCTURE_FIELDS[$intent['table']]) === [], 422, 'Campos fora do âmbito da estrutura.');
if (array_key_exists('primary_territory_id', $intent['fields'])) {
$territory = $intent['fields']['primary_territory_id'];
$path = $territory ? app(InvestmentGeography::class)->path($territory, true) : [];
abort_if($territory && $path === [], 422, 'Localização proposta indisponível.');
abort_if(count($path) > 4, 422, 'Hierarquia territorial proposta inválida.');
$context = [];
foreach ($path as $index => $node) {
$location = DB::table('geo_locations')->where('id', $node->id)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first();
abort_unless($location && $location->location_type === ['province', 'district', 'admin_post', 'locality'][$index], 422, 'Hierarquia territorial proposta indisponível.');
$context[] = Arr::only(get_object_vars($location), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'external_id', 'source_code']);
}
$fields['_structure']['territory_context'] = $context;
}
}
return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]];
}
/** @param EditorialPayload $payload
* @return EditorialPayload
*/
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']);
}
return ($edition->dossier_id ?? null) ? $this->portfolioSnapshot($payload['resource'], $payload['contributors'])
: $this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']);
}
public function demographyAvailable(): bool
{
return (bool) config('editorial_workflow.demography') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'demographic_import_id');
}
public function demographyWrites(string $id): bool
{
if (config('editorial_workflow.demography')) {
abort_unless($this->demographyAvailable(), 503, 'Estrutura editorial da demografia ainda não instalada.');
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'demographic_import_id') && DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->exists(), 503, 'Reactive o circuito editorial para disponibilizar este lote.');
return false;
}
public function demographyAccess(User $actor): bool
{
if (! $this->demographyAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
try {
app(DemographicImport::class)->authorize($actor);
return true;
} catch (HttpException $exception) {
if ($exception->getStatusCode() === 403) {
return false;
}
throw $exception;
}
}
public function demographyPending(User $actor): int
{
if (! $this->demographyAccess($actor)) {
return 0;
}
return DB::table('portal_editorial_editions as e')->whereNotNull('e.demographic_import_id')->whereNotIn('e.state', ['published', 'archived'])
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.demographic_import_id', 'e.demographic_import_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count();
}
public function authorizeDemography(User $actor, string $id, bool $write = false): void
{
abort_unless($this->demographyAvailable(), 503);
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
app(DemographicImport::class)->authorize($actor);
abort_unless(DB::table('demographic_imports')->where('id', $id)->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function latestDemography(string $id): ?\stdClass
{
return DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->orderByDesc('revision_no')->first();
}
/** Bounded bucket aggregates: never load the observation dataset into PHP memory.
* @param list<int> $contributors
* @param array{independent_required:bool} $policy
* @return EditorialPayload
*/
private function demographySnapshot(string $id, array $contributors, array $policy): array
{
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch && $batch->status !== 'prepared', 422, 'Conclua primeiro a importação privada e as correspondências territoriais.');
$path = Storage::disk('local')->path('demography/'.$id.'/original.xlsx');
$preview = Storage::disk('local')->path('demography/'.$id.'/preview.json');
abort_unless(CheckedFile::matches($path, trim($batch->sha256)) && CheckedFile::matches($preview, trim($batch->preview_sha256)), 409, 'O original ou a prévia mudou. Conserve-os e prepare uma nova edição.');
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
abort_unless(($report['scan']['result'] ?? '') === 'clean' && ($report['scan']['sha256'] ?? '') === trim($batch->sha256), 422, 'Original com análise antimalware limpa e vinculada ao hash necessário.');
$buckets = DB::select("SELECT left(md5(o.id::text),2) AS bucket, count(*) AS records, encode(sha256(convert_to(string_agg(encode(sha256(convert_to(to_jsonb(o)::text,'UTF8')),'hex'),'' ORDER BY o.id),'UTF8')),'hex') AS digest FROM demographic_observations o WHERE o.import_id=? GROUP BY left(md5(o.id::text),2) ORDER BY bucket", [$id]);
$count = array_sum(array_map(fn ($bucket) => (int) $bucket->records, $buckets));
abort_unless($count > 0 && $count === (int) ($report['record_count'] ?? -1), 422, 'Contagem do lote diferente da importação conferida.');
$territories = DB::table('demographic_observations')->where('import_id', $id)->distinct()->pluck('geo_location_id');
$contexts = DB::table('geo_locations')->whereIn('id', $territories)->orderBy('id')->get(['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'deleted_at']);
abort_unless($contexts->count() === $territories->count() && $contexts->every(fn ($row) => $row->deleted_at === null), 422, 'Correspondência territorial do lote indisponível.');
$fields = ['id' => $id, 'title' => $batch->file_name, 'source' => $batch->source, 'publication_version' => $batch->publication_version,
'sha256' => trim($batch->sha256), 'preview_sha256' => trim($batch->preview_sha256), 'report' => $report, 'observations' => $count,
'dataset_hash' => $this->hash(['buckets' => array_map(fn ($bucket) => get_object_vars($bucket), $buckets)])];
return ['resource' => $fields, 'links' => array_values($contexts->map(fn ($row) => ['target_type' => 'territory', 'target_key' => $row->id, 'context' => get_object_vars($row)])->all()), 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => $policy];
}
public function saveDemography(User $actor, string $id, int $expected): \stdClass
{
$this->authorizeDemography($actor, $id, true);
return DB::transaction(function () use ($actor, $id, $expected) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch && in_array($batch->status, ['imported', 'disabled'], true), 409, 'O lote já disponibilizado é preservado. Uma nova fonte deve ser um lote próprio.');
$previous = $this->latestDemography($id);
abort_unless((int) ($previous->revision_no ?? 0) === $expected, 409, 'A edição do lote mudou. Recupere a versão actual.');
$contributors = array_values(array_unique([$actor->id, (int) $batch->prepared_by]));
$snapshot = $this->demographySnapshot($id, $contributors, ['independent_required' => in_array('demography', config('editorial_workflow.independent_approval.kinds', []), true) || ($previous ? $this->requiresIndependent($previous) : false)]);
if ($previous) {
abort_unless(hash_equals($this->hash(Arr::except($this->payload($previous)['resource'], ['status'])), $this->hash(Arr::except($snapshot['resource'], ['status']))), 409, 'O original, as observações ou a proveniência do lote mudaram. Preserve-os; não confirme alterações silenciosas como nova revisão.');
}
$edition = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $edition, 'demographic_import_id' => $id, 'revision_no' => $expected + 1,
'state' => 'draft', 'version' => 1, '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()]);
$record = DB::table('portal_editorial_editions')->where('id', $edition)->first();
$this->audit($actor, $record, 'draft_saved', 'Revisão privada do lote preparada; observações e lotes anteriores preservados.');
return $record;
});
}
/** @param EditorialPayload $payload */
private function publishDemography(User $actor, \stdClass $batch, \stdClass $edition, array $payload, string $reason): void
{
abort_unless(in_array($batch->status, ['imported', 'disabled'], true), 409, 'Estado de disponibilização do lote alterado.');
DB::table('demographic_observations')->where('import_id', $batch->id)->orderBy('id')->lockForUpdate()->lazyById(1000, 'id')->each(static fn (\stdClass $row) => null);
DB::table('geo_locations')->whereIn('id', array_column($payload['links'], 'target_key'))->orderBy('id')->lockForUpdate()->get(['id']);
abort_unless(hash_equals($edition->content_hash, $this->hash($this->refreshPayload($edition, $payload))), 409, 'Dados ou correspondências alterados. Obtenha nova aprovação.');
$this->demographyStatus($actor, $batch, 'approved', $reason, $edition->id);
}
private function demographyStatus(User $actor, \stdClass $batch, string $status, string $reason, string $edition): void
{
DB::table('demographic_imports')->where('id', $batch->id)->update(['status' => $status, 'updated_at' => now()]);
DB::table('demographic_events')->insert(['import_id' => $batch->id, 'actor_id' => $actor->id, 'action' => $status, 'reason' => $reason,
'details' => json_encode(['previous_status' => $batch->status, 'edition_id' => $edition], JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
/** @param array<string,mixed> $changes */
public function savePortfolio(User $actor, InvestmentDossier $record, int $expected, array $changes): \stdClass
{
$this->authorizePortfolio($actor, $record->id, true);
return DB::transaction(function () use ($actor, $record, $expected, $changes) {
$record = InvestmentDossier::whereKey($record->id)->lockForUpdate()->firstOrFail();
abort_unless((int) $this->workingPortfolio($record)->getAttribute('version_no') === $expected, 409, 'A edição mudou. Recupere a versão actual antes de guardar.');
$previous = $this->latestPortfolio($record->id);
$public = $this->portfolioSnapshot($record->getAttributes(), []);
if (! $previous && $record->getAttribute('publication_status') === 'published') {
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'dossier_id' => $record->id, 'revision_no' => $expected,
'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public),
'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]);
}
$base = $previous ? $this->payload($previous) : $public;
if ($previous && $previous->published_at !== null) {
unset($base['resource']['_structure']);
}
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content', '_structure']), ['version_no' => $expected + 1]);
$contributors = array_values(array_unique([...($previous && $previous->published_at === null ? $base['contributors'] : []), $actor->id]));
$snapshot = $this->portfolioSnapshot($fields, $contributors);
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1,
'state' => 'draft', 'version' => 1, '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)->first();
$this->audit($actor, $edition, 'draft_saved', 'Edição privada da carteira guardada; publicação preservada.');
return $edition;
});
}
/** @param EditorialPayload $payload */
private function publishPortfolio(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
{
if ($intent = $payload['resource']['_structure'] ?? null) {
abort_unless(in_array($intent['table'], ['projects', 'economic_clusters', 'development_corridors'], true), 422);
$model = ['projects' => Project::class, 'economic_clusters' => EconomicCluster::class, 'development_corridors' => DevelopmentCorridor::class][$intent['table']];
$target = $model::whereKey($intent['id'])->lockForUpdate()->first();
$territories = array_column($intent['territory_context'] ?? [], 'id');
if ($territories !== []) {
DB::table('geo_locations')->whereIn('id', $territories)->orderBy('id')->lockForUpdate()->get();
}
abort_unless($target !== null && hash_equals($edition->content_hash, $this->hash($this->refreshPayload($edition, $payload))), 409, 'O contexto foi alterado. Obtenha nova aprovação.');
try {
PortalAudit::save($target, $intent['fields'], $actor->id);
if (array_key_exists('primary_territory_id', $intent['fields'])) {
DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $intent['table'], 'actor_user_id' => $actor->id, 'action' => 'primary_territory_saved',
'after_data' => json_encode(['record_id' => $intent['id'], 'territory_id' => $intent['fields']['primary_territory_id'], 'version' => $intent['fields']['territory_version']], JSON_THROW_ON_ERROR)]);
}
} catch (QueryException $exception) {
if (in_array($exception->errorInfo[0] ?? '', ['23505', '23514'], true)) {
abort(409, 'Os dados-base deixaram de ser aplicáveis ou o código já está em uso. Guarde nova edição antes de publicar.');
}
throw $exception;
}
}
$approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
$fields = Arr::only($payload['resource'], ['title', 'source', 'content']);
$fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR);
DB::table('investment_dossiers')->where('id', $record->id)->update([...$fields, 'version_no' => max((int) $record->version_no + 1, (int) $edition->revision_no),
'editor_id' => $edition->author_id, 'reviewer_id' => $approval['actor_id'], 'approval_basis' => 'manager', 'approval_evidence' => $approval['reason'], 'publication_status' => 'published', 'published_at' => now(), 'updated_at' => now()]);
}
/** @param array<string,mixed> $changes
* @param list<array<string,mixed>>|null $links
*/
public function save(User $actor, int $resource, int $expectedVersion, array $changes, ?array $links = null): \stdClass
{
$this->authorize($actor, $resource, true);
return DB::transaction(function () use ($actor, $resource, $expectedVersion, $changes, $links) {
$r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first();
abort_unless($r !== null, 404);
abort_unless((int) $this->workingResource($r)->metadata_version === $expectedVersion, 409, 'A edição mudou. Recupere a versão actual antes de guardar.');
$previous = $this->latest($resource);
if (! $previous && $r->published) {
$public = $this->snapshot((array) $r, array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all()));
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'resource_id' => $resource, 'revision_no' => $expectedVersion,
'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public),
'author_id' => $r->created_by ?? $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]);
}
$base = $previous ? $this->payload($previous) : ['resource' => (array) $r, 'links' => array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all())];
$fields = array_replace($base['resource'], Arr::except($changes, ['_document', 'id', 'created_by', 'approved_by', 'published', 'metadata_version', 'created_at', 'updated_at', 'independent_review']));
$fields['metadata_version'] = $expectedVersion + 1;
$fields['independent_review'] = false;
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? ($base['contributors'] ?? [$previous->author_id]) : []), $actor->id]));
$snapshot = $this->snapshot($fields, $links ?? $base['links'], Arr::only($changes['_document'] ?? $base['document_changes'] ?? [], ['issuer', 'issued_on', 'rights_status', 'data_classification']), $contributors, ['independent_required' => $this->independentPolicy($resource, $fields)]);
foreach ($snapshot['files'] as $file) {
abort_unless($this->canReadFile($actor, $file['id']), 403);
}
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'resource_id' => $resource, 'revision_no' => $expectedVersion + 1,
'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot),
'author_id' => $actor->id, 'legal_required' => (bool) ($r->legislation || ($previous->legal_required ?? false) || ($fields['legislation'] ?? false)) || ($fields['document_type'] ?? '') === 'legislation', 'created_at' => now(), 'updated_at' => now()]);
$edition = DB::table('portal_editorial_editions')->where('id', $id)->first();
$this->audit($actor, $edition, 'draft_saved', 'Edição privada guardada; versão pública preservada.');
return $edition;
});
}
/** @return array<string,mixed> */
private function decision(User $actor, \stdClass $edition, string $reason): array
{
return ['actor_id' => $actor->id, 'edition_id' => $edition->id, 'hash' => $edition->content_hash, 'reason' => $reason, 'at' => now()->toISOString()];
}
/** @param array<string,mixed> $input */
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false, ?string $subject = null): \stdClass
{
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);
} else {
$this->authorize($actor, (int) $resource, true);
}
$reason = $portfolio && in_array($input['action'] ?? '', ['approve', 'publish'], true) ? 'required|string|min:15|max:3000' : 'required|string|min:3|max:5000';
if ($demography) {
$reason = 'required|string|min:10|max:2000';
}
$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, $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 = $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($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 || ($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'],
default => abort(422, 'Operação editorial desconhecida.'),
};
abort_unless(in_array($e->state, $allowed, true), 409, 'Transição incompatível com o estado actual.');
$payload = $this->payload($e);
// Content is immutable; changed files invalidate the decision rather than being silently accepted.
if (in_array($action, ['submit', 'approve', 'legal_validate', 'publish'], true)) {
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 || $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)) {
foreach ($payload['files'] as $file) {
abort_unless($file['status'] === 'active' && $file['deleted_at'] === null && $file['malware_scan_status'] === 'clean' && hash_equals(trim($file['sha256']), trim((string) $file['malware_scanned_sha256'])), 422, 'Conclua a análise dos anexos antes da aprovação.');
}
}
if ($action === 'legal_validate') {
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') {
if ($e->legal_required) {
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
$updates['approval'] = json_encode($this->decision($actor, $e, $data['reason']), JSON_THROW_ON_ERROR);
}
if ($action === 'publish') {
$this->assertDecision($e, $e->approval, 'Aprovação desta edição necessária.');
if ($e->legal_required) {
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
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);
} else {
$this->publish($actor, $r, $e, $payload);
}
$updates['published_at'] = now();
}
if ($action === 'submit' && isset($data['assigned_to'])) {
$responsible = User::query()->whereKey($data['assigned_to'])->firstOrFail();
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 ($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()]);
} else {
DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]);
}
}
$updates['state'] = match ($action) {
'submit' => 'submitted', 'start_review', 'legal_validate' => 'in_review', 'return' => 'returned', 'reject' => 'rejected', 'approve' => 'approved', 'publish' => 'published', 'archive' => 'archived',
};
$updates['version'] = (int) $e->version + 1;
$updates['updated_at'] = now();
DB::table('portal_editorial_editions')->where('id', $id)->update($updates);
$this->audit($actor, $e, $action, $data['reason'], $updates['state']);
foreach (array_unique(array_filter([$e->author_id, $updates['assigned_to'] ?? $e->assigned_to])) as $recipient) {
if ((int) $recipient !== $actor->id) {
DB::table('portal_editorial_notifications')->insert(['edition_id' => $id, 'user_id' => $recipient, 'action' => $action, 'created_at' => now()]);
}
}
return DB::table('portal_editorial_editions')->where('id', $id)->first();
});
}
private function assertDecision(\stdClass $e, ?string $decision, string $message, bool $legal = false): void
{
$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() && (($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($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.');
}
}
/** @param array<string,mixed> $payload */
private function publish(User $actor, \stdClass $r, \stdClass $e, array $payload): void
{
$fields = $payload['resource'];
foreach ($payload['files'] as $file) {
if ($file['id'] === $fields['document_id']) {
$file = array_replace($file, $payload['document_changes']);
}
abort_unless($file['status'] === 'active' && $file['deleted_at'] === null && $file['rights_status'] === 'authorized' && $file['data_classification'] === 'public'
&& $file['malware_scan_status'] === 'clean' && hash_equals(trim($file['sha256']), trim((string) $file['malware_scanned_sha256'])), 422, 'Anexos públicos, autorizados e verificados necessários.');
}
abort_unless(($fields['rights_status'] ?? '') === 'authorized' && ($fields['source_confirmation'] ?? 'unconfirmed') !== 'unconfirmed' && ! empty($fields['source_url']), 422, 'Direitos e fonte necessários.');
abort_unless(! (($fields['legislation'] ?? false) || ($fields['document_type'] ?? '') === 'legislation') || ($fields['source_confirmation'] ?? '') === 'official_confirmed', 422, 'Confirmação da fonte legislativa necessária.');
$changes = $payload['document_changes'];
if ($changes !== []) {
$document = DB::table('documents')->where('id', $fields['document_id'])->lockForUpdate()->first();
$shared = DB::table('portal_resources')->where('document_id', $document->id)->where('id', '!=', $r->id)->where('published', true)->exists();
foreach ($changes as $key => $value) {
abort_if($shared && $value != $document->$key, 422, 'Crie uma nova versão do ficheiro para alterar metadados partilhados com outra publicação.');
}
DB::table('documents')->where('id', $document->id)->update([...$changes, 'updated_at' => now()]);
}
$fields = Arr::except($fields, ['id', 'metadata_version', 'created_at', 'created_by', 'updated_at', 'published', 'approved_by', 'independent_review']);
$fields['independent_review'] = ! in_array(json_decode($e->approval, true, 512, JSON_THROW_ON_ERROR)['actor_id'], $payload['contributors'], true);
DB::table('portal_resources')->where('id', $r->id)->update([...$fields, 'published' => true, 'metadata_version' => max((int) $r->metadata_version + 1, (int) $e->revision_no), 'approved_by' => json_decode($e->approval, true, 512, JSON_THROW_ON_ERROR)['actor_id'], 'updated_at' => now()]);
DB::table('portal_resource_links')->where('resource_id', $r->id)->delete();
foreach ($payload['links'] as $link) {
DB::table('portal_resource_links')->insert([...Arr::except($link, ['id', 'resource_id', 'created_at', 'updated_at']), 'resource_id' => $r->id, 'created_at' => now(), 'updated_at' => now()]);
}
}
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, '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]);
}
}
+94 -10
View File
@@ -7,6 +7,64 @@ use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms
{
/** @return array{type:string,category:?string,label:string,kind:?string,filter:string}|null */
public function requestIntent(string $query): ?array
{
if ($count = $this->countIntent($query)) {
return ['type' => 'count', ...$count];
}
$normalized = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
if (preg_match('/^(?:como|onde|quero|preciso de)\s+(?:(?:posso|faco para|devo)\s+)?licenciar(?:$|\s+(?:(?:uma|a|minha|o|um|meu)\s+)?(?:empresa|negocio|actividade|atividade|investimento)\b)/', $normalized)) {
return ['type' => 'guide', 'category' => 'Páginas e serviços', 'label' => 'Licenciamento da empresa', 'kind' => null, 'filter' => 'licenciamento'];
}
$product = null;
if (preg_match('/^(?:(?:tipos?|lista|catalogo) de |quais (?:sao )?(?:os )?)?produtos?\b(.*)$/', $normalized, $match)) {
$product = $match[1];
} elseif (preg_match('/^onde (?:encontrar|produzir|se produz|existem|ha)\s+(.+)$/', $normalized, $match)) {
$product = $match[1];
}
if ($product !== null) {
$category = preg_match('/\bfinanceiros?\b/', $product) ? 'Produtos financeiros' : (preg_match('/\bfisicos?\b/', $product) ? 'Produtos e localizações' : null);
$product = preg_replace('/\b(?:e (?:a )?(?:sua )?localizacao|e (?:as )?(?:suas )?localizacoes|financeiros?|fisicos?|existem|disponiveis|no portal)\b/', ' ', $product);
return ['type' => 'products', 'category' => $category, 'label' => 'Produtos e localização', 'kind' => null, 'filter' => trim(preg_replace('/\s+/', ' ', $product))];
}
if (preg_match('/^(?:listar|mostrar|mostre|ver|quais (?:sao )?(?:os|as))\s+(.+)$/', $normalized, $match)
&& ($list = $this->countIntent('quantos '.$match[1]))) {
return ['type' => 'list', ...$list];
}
return null;
}
/** @return array{category:string,label:string,kind:?string,filter:string}|null */
public function countIntent(string $query): ?array
{
$query = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
$types = [
'corredor|corredores' => ['Carteira: projectos, clusters e corredores', 'corredores', 'corridor'],
'cluster|clusters' => ['Carteira: projectos, clusters e corredores', 'clusters', 'cluster'],
'projectos? ancora|projetos? ancora' => ['Carteira: projectos, clusters e corredores', 'projectos âncora', 'project'],
'oportunidade|oportunidades' => ['Oportunidades', 'oportunidades', null],
'negocio|negocios' => ['Negócios', 'negócios', null],
'documento|documentos' => ['Documentos e legislação', 'documentos', null],
'artigo|artigos' => ['Artigos', 'artigos', null],
'sector|sectores|setor|setores' => ['Sectores', 'sectores', null],
'localizacao|localizacoes' => ['Localizações', 'localizações', null],
'zonas? de investimento|zona|zonas' => ['Zonas de investimento', 'zonas de investimento', null],
'parceria|parcerias' => ['Parcerias', 'parcerias', null],
];
foreach ($types as $pattern => [$category, $label, $kind]) {
if (preg_match('/^(?:quantos|quantas|numero de|quantidade de|total de|qual (?:e )?o numero de)\s+(?:'.$pattern.')\b(.*)$/u', $query, $match)) {
$filter = preg_replace('/\b(?:no portal|no investemoz|em mocambique|de investimento|existem|existe|existentes|existente|ha|temos|estao|sao|disponiveis|disponivel|publicados|publicadas|registados|registadas|cadastrados|cadastradas)\b/u', ' ', $match[1]);
return ['category' => $category, 'label' => $label, 'kind' => $kind, 'filter' => trim(preg_replace('/\s+/', ' ', $filter))];
}
}
return null;
}
/** @return list<string> */
public function tokens(string $query): array
{
@@ -24,13 +82,16 @@ final class SearchTerms
return $tokens;
}
/** Known Portuguese spelling and number variants; do not infer semantic equivalence. */
/** Known Portuguese spelling and number variants; do not infer semantic equivalence.
* @return list<string>
*/
public function variants(string $token): array
{
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) {
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas'],
['banco', 'bancos'], ['seguro', 'seguros'], ['produto', 'produtos'], ['agricola', 'agricolas']] as $group) {
if (in_array($token, $group, true)) {
return $group;
}
@@ -44,7 +105,10 @@ final class SearchTerms
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
}
/** Columns are trusted application constants, never user input. */
/** Columns are trusted application constants, never user input.
* @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<literal-string> $columns
*/
public function orFields($query, array $columns, string $token): void
{
foreach ($columns as $column) {
@@ -54,6 +118,10 @@ final class SearchTerms
}
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<literal-string> $columns
* @param list<string> $tokens
*/
public function constrain($query, array $columns, array $tokens): void
{
foreach ($tokens as $token) {
@@ -61,6 +129,7 @@ final class SearchTerms
}
}
/** @param list<string> $tokens */
public function matches(string $text, array $tokens): bool
{
$text = mb_strtolower(Str::ascii($text));
@@ -73,40 +142,55 @@ final class SearchTerms
return true;
}
/** @param literal-string $column
* @return literal-string
*/
private function normalizedColumn(string $column): string
{
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
}
/** @param list<string> $tokens */
public function score(string $title, string $phrase, array $tokens): int
{
$title = mb_strtolower(Str::ascii($title));
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$score = $title === $phrase ? 1000 : (str_contains($title, $phrase) ? 400 : 0);
$score = $title === $phrase ? 1000 : (str_starts_with($title, $phrase) ? 600 : (str_contains($title, $phrase) ? 400 : 0));
foreach ($tokens as $token) {
if ($this->matches($title, [$token])) {
$score += 20;
$score++;
}
}
return $score;
}
/** The same title score used for in-memory pages, with bound search terms. */
/** The same title score used for in-memory pages, with bound search terms.
* @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param literal-string $titleColumn
* @param literal-string $tieColumn
*/
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{
$column = $this->normalizedColumn($titleColumn);
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
$bindings = [$phrase, $this->pattern($phrase)];
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 600 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
$bindings = [$phrase, ltrim($this->pattern($phrase), '%'), $this->pattern($phrase)];
foreach ($tokens as $token) {
$conditions = [];
foreach ($this->variants($token) as $variant) {
$conditions[] = "{$column} LIKE ? ESCAPE '!'";
$bindings[] = $this->pattern($variant);
}
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 20 ELSE 0 END';
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END';
}
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
$query->orderByRaw('('.$sql.') DESC', $bindings);
if (str_contains($titleColumn, '(')) {
$query->orderByRaw($titleColumn);
} else {
$query->orderBy($titleColumn);
}
$query->orderBy($tieColumn);
}
}
+302 -4
View File
@@ -3,9 +3,12 @@
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -36,8 +39,12 @@ final class TerritorialRegistry
'location_type' => ['required', 'in:'.implode(',', array_keys(self::PARENTS))],
'parent_id' => ['nullable', 'uuid', 'exists:geo_locations,id'], 'source_id' => ['required', 'uuid', 'exists:geo_sources,id'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'], 'confirm_baseline_change' => ['sometimes', 'boolean'],
])->validate();
$this->ensureWriteMode($id);
if ($this->editorialAvailable()) {
return $this->saveEditorial($actor, $data, $id);
}
return DB::transaction(function () use ($actor, $data, $id) {
// Serialise hierarchy changes to prevent concurrent parent cycles and duplicate paths.
@@ -68,12 +75,303 @@ final class TerritorialRegistry
});
}
public function snapshot(string $id, int $version, User $actor, string $reason): void
/** @param array<string,mixed>|null $proposal */
public function snapshot(string $id, int $version, User $actor, string $reason, ?array $proposal = null): string
{
$snapshot = json_encode(['location' => DB::table('geo_locations')->find($id),
$snapshot = json_encode($proposal ?? ['location' => DB::table('geo_locations')->find($id),
'profile' => DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first()], JSON_THROW_ON_ERROR);
DB::table('geo_territory_revisions')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id,
$revision = (string) Str::uuid();
DB::table('geo_territory_revisions')->insert(['id' => $revision, 'geo_location_id' => $id,
'version_no' => $version, 'actor_id' => $actor->id, 'reason' => Crypt::encryptString($reason),
'snapshot' => Crypt::encryptString($snapshot), 'checksum' => hash('sha256', $snapshot)]);
return $revision;
}
private const EDITORIAL_SOURCE = 'territorial-editorial-v1';
public function editorialAvailable(): bool
{
return (bool) config('editorial_workflow.territorial_versions') && (bool) config('territorial.enabled') && Schema::hasTable('geo_territory_revisions') && Schema::hasTable('audit_events');
}
public function ensureWriteMode(?string $id): void
{
if (config('editorial_workflow.territorial_versions')) {
abort_unless($this->editorialAvailable(), 503, 'Circuito territorial ainda não disponível.');
} elseif ($id) {
abort_if(DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->exists(), 503, 'Reactive o circuito para conservar a revisão territorial privada.');
}
}
public function hasEditorialHistory(string $id): bool
{
return DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->exists();
}
/** @return array<string,mixed> */
public function preview(string $id): array
{
$state = $this->working($id);
$history = DB::table('geo_territory_revisions')->where('geo_location_id', $id)->orderByDesc('version_no')->limit(100)->get()
->map(function ($revision) {
try {
$json = Crypt::decryptString($revision->snapshot);
$reason = Crypt::decryptString($revision->reason);
} catch (DecryptException) {
abort(409, 'Histórico territorial indisponível; preserve o original.');
}
abort_unless(hash_equals(trim($revision->checksum), hash('sha256', $json)), 409, 'Histórico territorial alterado.');
return ['id' => $revision->id, 'version_no' => $revision->version_no, 'actor_id' => $revision->actor_id,
'reason' => $reason, 'snapshot' => json_decode($json, true, 512, JSON_THROW_ON_ERROR)];
});
return ['working' => $state, 'public' => $this->live($id), 'history' => $history];
}
/** @return array<string,mixed> */
private function live(string $id): array
{
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->first();
$profile = DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first();
abort_unless($location && $profile, 404);
return ['location' => get_object_vars($location), 'profile' => get_object_vars($profile)];
}
/** @param array<string,mixed> $value */
private function digest(array $value): string
{
return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function working(string $id): array
{
$live = $this->live($id);
$event = DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->orderByRaw("(after_data->>'version_no')::integer DESC")->first();
if ($event) {
$details = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$revision = DB::table('geo_territory_revisions')->where('id', $details['revision_id'])->where('geo_location_id', $id)->first();
abort_unless($revision && hash_equals(trim($revision->checksum), $details['checksum']), 409, 'Referência de revisão territorial alterada.');
try {
$json = Crypt::decryptString($revision->snapshot);
} catch (DecryptException) {
abort(409, 'Revisão territorial indisponível; conserve o histórico.');
}
abort_unless(hash_equals(trim($revision->checksum), hash('sha256', $json)), 409, 'Conteúdo da revisão territorial alterado.');
$state = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
abort_unless($state['location']['id'] === $id && $state['location']['version_no'] === (int) $revision->version_no, 409);
if ($state['_editorial']['private']) {
$state['public_version_no'] = $live['profile']['status'] === 'published' ? $live['location']['version_no'] : null;
$state['baseline_changed'] = ! hash_equals($state['_editorial']['baseline_hash'], $this->digest($live));
$state['fresh'] = ! $state['baseline_changed'] && hash_equals($state['_editorial']['content_hash'], $this->contentHash($state));
return $state;
}
$live['_editorial'] = $state['_editorial'];
}
$live['public_version_no'] = $live['profile']['status'] === 'published' ? $live['location']['version_no'] : null;
$live['location']['version_no'] = max((int) $live['location']['version_no'], (int) DB::table('geo_territory_revisions')->where('geo_location_id', $id)->max('version_no'));
$live['baseline_changed'] = false;
$live['fresh'] = true;
return $live;
}
/** @param array<string,mixed> $state
* @return array<string,mixed>
*/
private function evidence(array $state): array
{
$source = DB::table('geo_sources')->where('id', $state['profile']['source_id'])->first();
abort_unless($source !== null, 422, 'Fonte indisponível.');
$parents = [];
$parent = $state['location']['parent_id'];
$seen = [$state['location']['id']];
while ($parent) {
abort_if(in_array($parent, $seen, true) || count($seen) > 5, 422, 'Hierarquia territorial incoerente.');
$seen[] = $parent;
$row = DB::table('geo_locations')->where('id', $parent)->whereNull('deleted_at')->first(['id', 'name', 'code', 'parent_id', 'location_type', 'version_no']);
abort_unless($row !== null, 422, 'Ascendente indisponível.');
$parents[] = get_object_vars($row);
$parent = $row->parent_id;
}
return ['source' => Arr::only(get_object_vars($source), ['id', 'name', 'responsible_entity', 'reference', 'license', 'checksum', 'source_version', 'collected_at', 'original_crs']), 'parents' => $parents];
}
/** @param array<string,mixed> $state */
private function contentHash(array $state): string
{
return $this->digest(['location' => Arr::only($state['location'], ['id', 'name', 'code', 'location_type', 'parent_id']),
'profile' => Arr::only($state['profile'], ['source_id', 'valid_from', 'valid_to']),
'contributors' => $state['_editorial']['contributors'], 'evidence' => $this->evidence($state)]);
}
private function editorialActor(User $actor, string $role): void
{
abort_unless($actor->can('manage-portal') && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! app(PortalProfileAccess::class)->has($actor, 'auditor')
&& in_array(strtolower($actor->email), config('territorial.'.$role, []), true) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Função territorial e MFA válido necessários.');
}
/** @param array<string,mixed> $data */
private function shape(array $data, ?string $id): void
{
$parentType = self::PARENTS[$data['location_type']];
$parent = empty($data['parent_id']) ? null : DB::table('geo_locations')->where('id', $data['parent_id'])->whereNull('deleted_at')->first();
abort_unless($parentType ? $parent && $parent->location_type === $parentType : ! $parent, 422, 'Hierarquia territorial incoerente.');
$duplicate = DB::table('geo_locations')->where('location_type', $data['location_type'])->whereNull('deleted_at')->when($id, fn ($q) => $q->where('id', '<>', $id))
->where(fn ($q) => $q->where('code', $data['code'])->orWhere(fn ($q) => $q->where('parent_id', $data['parent_id'] ?? null)->whereRaw('lower(trim(name))=lower(trim(?))', [$data['name']])))->exists();
abort_if($duplicate, 422, 'Código ou nome no mesmo pai já existente.');
}
/** @param array<string,mixed> $state */
private function appendEditorial(User $actor, string $id, array $state, string $reason, string $action): void
{
unset($state['fresh'], $state['baseline_changed'], $state['public_version_no']);
$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');
$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<string,mixed> $data */
private function saveEditorial(User $actor, array $data, ?string $id): string
{
$this->editorialActor($actor, 'editors');
return DB::transaction(function () use ($actor, $data, $id) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$this->shape($data, $id);
if (! $id) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'name' => $data['name'], 'code' => $data['code'], 'location_type' => $data['location_type'], 'parent_id' => $data['parent_id'] ?? null,
'version_no' => 1, 'data_classification' => 'internal', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->insert(['geo_location_id' => $id, 'source_id' => $data['source_id'], 'valid_from' => $data['valid_from'], 'valid_to' => $data['valid_to'] ?? null, 'status' => 'draft', 'editor_id' => $actor->id]);
$state = $this->live($id);
} else {
DB::table('geo_locations')->where('id', $id)->lockForUpdate()->first();
$state = $this->working($id);
abort_unless((int) $state['location']['version_no'] === (int) $data['version_no'], 409, 'Versão territorial alterada.');
abort_unless($state['location']['location_type'] === $data['location_type'], 422, 'O tipo territorial é imutável.');
abort_if(($state['baseline_changed'] ?? false) && ! ($data['confirm_baseline_change'] ?? false), 409, 'Compare a publicação actual e confirme a reconciliação antes de guardar.');
if (! $this->hasEditorialHistory($id) && ! DB::table('geo_territory_revisions')->where('geo_location_id', $id)->where('version_no', $state['location']['version_no'])->exists()) {
$this->snapshot($id, $state['location']['version_no'], $actor, 'Conservação do estado anterior ao circuito editorial; não constitui nova aprovação.');
}
$state['location']['version_no']++;
}
$private = $state['_editorial']['private'] ?? false;
$contributors = array_values(array_unique([...($private ? $state['_editorial']['contributors'] : []), $actor->id]));
$state['location'] = array_replace($state['location'], Arr::only($data, ['name', 'code', 'location_type', 'parent_id']), ['data_classification' => 'internal']);
$state['profile'] = array_replace($state['profile'], Arr::only($data, ['source_id', 'valid_from', 'valid_to']), ['status' => 'draft', 'editor_id' => $actor->id, 'reviewer_id' => null, 'publisher_id' => null]);
$state['_editorial'] = ['private' => true, 'edition_no' => $state['location']['version_no'], 'contributors' => $contributors, 'approval' => null, 'baseline_hash' => $this->digest($this->live($id)), 'evidence' => $this->evidence($state)];
$state['_editorial']['content_hash'] = $this->contentHash($state);
$this->appendEditorial($actor, $id, $state, $data['reason'], 'draft_saved');
return $id;
});
}
/** @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function transitionEditorial(User $actor, string $id, array $data): array
{
$this->ensureWriteMode($id);
abort_unless($this->editorialAvailable(), 503);
$action = $data['action'];
$role = match ($action) {
'in_review', 'approved', 'rejected' => 'reviewers',
'published', 'archived', 'withdrawn' => 'publishers',
default => 'editors',
};
$this->editorialActor($actor, $role);
return DB::transaction(function () use ($actor, $id, $data, $action) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
DB::table('geo_locations')->where('id', $id)->lockForUpdate()->first();
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->lockForUpdate()->first();
$state = $this->working($id);
abort_unless((int) $state['location']['version_no'] === (int) $data['version_no'], 409, 'Versão territorial alterada.');
if ($action === 'published') {
$evidence = $this->evidence($state);
$parentIds = array_column($evidence['parents'], 'id');
DB::table('geo_locations')->whereIn('id', $parentIds)->orderBy('id')->lockForUpdate()->get();
DB::table('geo_territory_profiles')->whereIn('geo_location_id', $parentIds)->orderBy('geo_location_id')->lockForUpdate()->get();
$sourceIds = DB::table('geo_territory_profiles')->whereIn('geo_location_id', $parentIds)->pluck('source_id')->all();
$sourceIds[] = $state['profile']['source_id'];
DB::table('geo_sources')->whereIn('id', $sourceIds)->orderBy('id')->lockForUpdate()->get();
$state = $this->working($id);
}
$private = $state['_editorial']['private'] ?? false;
$live = $this->live($id);
if ($action === 'withdrawn' || ($action === 'archived' && ! $private)) {
abort_unless($live['profile']['status'] === 'published', 409);
$version = $state['location']['version_no'] + 1;
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update(['status' => 'archived', 'updated_at' => now()]);
DB::table('geo_locations')->where('id', $id)->update(['data_classification' => 'internal', 'version_no' => $version, 'updated_at' => now()]);
if ($private) {
$state['_editorial']['baseline_hash'] = $this->digest($this->live($id));
} else {
$state = $this->live($id);
$state['_editorial'] = ['private' => false];
}
$state['location']['version_no'] = $version;
$this->appendEditorial($actor, $id, $state, $data['reason'], 'publication_withdrawn');
return ['id' => $id, 'status' => $state['profile']['status'], 'version_no' => $version];
}
abort_unless($private, 409, 'Guarde uma versão privada antes de iniciar o novo circuito.');
$from = ['pending_review' => ['draft', 'rejected'], 'in_review' => ['pending_review'], 'approved' => ['in_review'],
'rejected' => ['pending_review', 'in_review'], 'published' => ['approved'], 'archived' => ['draft', 'pending_review', 'in_review', 'approved', 'rejected']];
abort_unless(isset($from[$action]) && in_array($state['profile']['status'], $from[$action], true), 409);
if (in_array($action, ['pending_review', 'approved', 'published'], true)) {
abort_unless($state['fresh'], 409, 'A publicação, fonte ou hierarquia mudou; reveja a versão.');
}
if ($action === 'pending_review') {
abort_unless($state['profile']['editor_id'] === $actor->id, 403);
}
if (in_array($action, ['in_review', 'approved', 'rejected'], true)) {
abort_if(in_array($actor->id, $state['_editorial']['contributors'], true), 403, 'Revisão independente obrigatória.');
$state['profile']['reviewer_id'] = $actor->id;
}
if ($action === 'approved') {
$state['_editorial']['approval'] = ['reviewer_id' => $actor->id, 'edition_no' => $state['_editorial']['edition_no'],
'content_hash' => $state['_editorial']['content_hash'], 'reason' => $data['reason'], 'at' => now()->toIso8601String()];
}
if ($action === 'published') {
$approval = $state['_editorial']['approval'];
abort_unless(is_array($approval) && $approval['edition_no'] === $state['_editorial']['edition_no']
&& hash_equals($approval['content_hash'], $this->contentHash($state)), 409, 'Aprovação não corresponde a esta versão.');
abort_if(in_array($actor->id, [...$state['_editorial']['contributors'], $approval['reviewer_id']], true), 403, 'Publicação exige terceiro interveniente.');
$reviewer = User::where('id', $approval['reviewer_id'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && $reviewer->two_factor_confirmed_at && $reviewer->two_factor_secret
&& ! app(PortalProfileAccess::class)->has($reviewer, 'auditor') && in_array(strtolower($reviewer->email), config('territorial.reviewers', []), true), 409, 'Revisor deixou de ter autoridade.');
abort_unless(DB::table('geo_sources')->where('id', $state['profile']['source_id'])->where('status', 'approved')->exists(), 422, 'Fonte não aprovada.');
abort_if($state['profile']['valid_from'] > today()->toDateString() || ($state['profile']['valid_to'] && $state['profile']['valid_to'] < today()->toDateString()), 422, 'Vigência inválida.');
abort_if($state['location']['parent_id'] && ! $this->published()->where('g.id', $state['location']['parent_id'])->exists(), 422, 'Ascendente não publicado.');
$this->shape($state['location'], $id);
$state['profile']['publisher_id'] = $actor->id;
}
$state['location']['version_no']++;
$state['profile']['status'] = $action;
if ($action === 'published') {
DB::table('geo_locations')->where('id', $id)->update([...Arr::only($state['location'], ['name', 'code', 'location_type', 'parent_id', 'version_no']), 'data_classification' => 'public', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update([...Arr::only($state['profile'], ['source_id', 'valid_from', 'valid_to', 'status', 'editor_id', 'reviewer_id', 'publisher_id']), 'updated_at' => now()]);
$state['_editorial']['private'] = false;
}
if ($action === 'archived') {
// Archiving a proposal never withdraws the existing public version.
$state['_editorial']['private'] = false;
}
$this->appendEditorial($actor, $id, $state, $data['reason'], $action);
return ['id' => $id, 'status' => $action, 'version_no' => $state['location']['version_no']];
});
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
$legalReviewerEmails = env('PORTAL_EDITORIAL_LEGAL_REVIEWERS', '');
return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
'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' => []],
];
@@ -0,0 +1,54 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('This first-stage migration is authorised only in isolated QA.');
}
Schema::create('portal_editorial_editions', function (Blueprint $t): void {
$t->uuid('id')->primary();
$t->foreignId('resource_id')->constrained('portal_resources')->restrictOnDelete();
$t->unsignedInteger('revision_no');
$t->unsignedInteger('version')->default(1);
$t->string('state', 24)->default('draft');
$t->jsonb('payload');
$t->char('content_hash', 64);
$t->foreignId('author_id')->constrained('users')->restrictOnDelete();
$t->foreignId('assigned_to')->nullable()->constrained('users')->restrictOnDelete();
$t->boolean('legal_required')->default(false);
$t->jsonb('approval')->nullable();
$t->jsonb('legal_validation')->nullable();
$t->timestampTz('published_at')->nullable();
$t->timestampsTz();
$t->unique(['resource_id', 'revision_no']);
$t->index(['state', 'assigned_to']);
});
Schema::create('portal_editorial_notifications', function (Blueprint $t): void {
$t->id();
$t->uuid('edition_id');
$t->foreign('edition_id')->references('id')->on('portal_editorial_editions')->restrictOnDelete();
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->string('action', 40);
$t->timestampTz('read_at')->nullable();
$t->timestampTz('created_at');
$t->index(['user_id', 'read_at']);
});
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->exists() || DB::table('portal_editorial_notifications')->exists()) {
throw new RuntimeException('Preserve editions, decisions and history.');
}
Schema::dropIfExists('portal_editorial_notifications');
Schema::dropIfExists('portal_editorial_editions');
}
};
@@ -0,0 +1,39 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
$t->uuid('edition_id')->nullable()->change();
$t->foreignId('opportunity_id')->nullable()->constrained('opportunities')->restrictOnDelete();
$t->unsignedInteger('opportunity_version')->nullable();
$t->char('content_hash', 64)->nullable();
$t->unique(['user_id', 'opportunity_id', 'opportunity_version', 'action'], 'portal_editorial_op_notice_uq');
});
DB::statement('ALTER TABLE portal_editorial_notifications 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))');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('opportunity_id')->exists()) {
throw new RuntimeException('Preserve opportunity notification history.');
}
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject');
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
$t->dropUnique('portal_editorial_op_notice_uq');
$t->dropForeign(['opportunity_id']);
$t->dropColumn(['opportunity_id', 'opportunity_version', 'content_hash']);
$t->uuid('edition_id')->nullable(false)->change();
});
}
};
@@ -0,0 +1,37 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_editions', function (Blueprint $t): void {
$t->unsignedBigInteger('resource_id')->nullable()->change();
$t->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete();
$t->unique(['dossier_id', 'revision_no']);
});
DB::statement('ALTER TABLE portal_editorial_editions ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id)=1)');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('dossier_id')->exists()) {
throw new RuntimeException('Preserve portfolio editorial history.');
}
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject');
Schema::table('portal_editorial_editions', function (Blueprint $t): void {
$t->dropUnique(['dossier_id', 'revision_no']);
$t->dropForeign(['dossier_id']);
$t->dropColumn('dossier_id');
$t->unsignedBigInteger('resource_id')->nullable(false)->change();
});
}
};
@@ -0,0 +1,35 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->foreignUuid('demographic_import_id')->nullable()->constrained('demographic_imports')->restrictOnDelete();
$table->unique(['demographic_import_id', 'revision_no'], 'editorial_demographic_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)=1)');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('demographic_import_id')->exists()) {
throw new RuntimeException('Preserve demographic editorial history.');
}
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)=1)');
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->dropUnique('editorial_demographic_revision_unique');
$table->dropForeign(['demographic_import_id']);
$table->dropColumn('demographic_import_id');
});
}
};
@@ -0,0 +1,35 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_notifications', function (Blueprint $table): void {
$table->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');
});
}
};
@@ -0,0 +1,35 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->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']);
});
}
};
@@ -1,3 +1,192 @@
## 14/11/2026 — correcção da hierarquia Administração → Dashboard
Clarificação expressa da Tempo: agrupar **todas as operações administrativas em Administração** e reservar **Dashboard** às funções dos painéis. A apresentação anterior misturava os cadastros do circuito editorial com o dashboard principal e conservava atalhos administrativos dentro de Mais. Esta correcção não acrescenta funcionalidades ou permissões.
Hierarquia comum ao cabeçalho e à navegação lateral, com grupos omitidos quando não têm destinos autorizados:
- **Administração → Dashboard:** Visão geral do painel `/dashboard`; O meu trabalho; manifestações, indicadores, pedidos recentes e acções rápidas quando o painel administrativo já os permite; circuito editorial, filas de revisão e notificações segundo os âmbitos existentes. Os investimentos próprios mantêm o contexto do painel aplicável. Os filtros e âncoras seleccionam a função actual.
- **Administração → Conteúdos e oportunidades:** gestão de conteúdos, documentos, oportunidades, preparação de análises e concursos autorizados.
- **Administração → Cadastros e investimento:** sectores, projectos, clusters, carteira, estruturas/relações, demografia, território, zonas, referências e educação, conforme a capacidade existente.
- **Administração → Pedidos e acompanhamento:** interesses, CRM e atribuições autorizadas.
- **Administração → Acessos e configuração:** gestão/auditoria de perfis e configurações já autorizadas.
Os cadastros deixam de figurar dentro de Dashboard; atalhos administrativos deixam de estar dispersos em Mais. Os destinos anteriores são preservados uma vez, incluindo funções de perfis antes em Área de trabalho. Funções pessoais e acessos públicos conservam a sua área. O painel `/dashboard` é a visão geral; `/management/editorial` volta a identificar-se como **Circuito editorial** e mantém os cartões/revisões existentes. Não se importam estados a partir de componentes de página; a correcção do manifesto permanece intacta.
QA: navegação/cabeçalho/sidebar **25 testes**, apresentação/secções **4**, acesso/inbox **4/35**, abertura HTML autenticada dos dois painéis com build real **1/11**, análise estática, tipos e compilação aprovados. Provas em `outputs/administration-dashboard-hierarchy-20261114`. Testa-se a hierarquia exacta, ausência de cadastros dentro de Dashboard, remoção das operações administrativas de Mais, preservação de destinos e âncoras reais. Instalação correctiva pelo processo Git existente com backup/restauro em curso; não declarar concluída até conferir a revisão servida. A conferência visual continua pendente pelo conector indisponível.
## 14/11/2026 — erro 500 corrigido e correcção instalada em dd20e747
A revisão `dd20e747036d45892faf3273a44e385132668ee4` foi promovida pelo executor Git para reparar a entrega do menu/dashboard. O manifesto servido volta a conter `resources/js/pages/management/editorial-workflow.tsx`. A geração do HTML completo no servidor com o template/manifesto reais e conteúdo exclusivamente sintético passou (200); o respectivo ficheiro compilado respondeu 200 por HTTPS. Não se simularam credenciais ou sessões privadas no destino. A abertura HTTP autenticada com o build real passou em QA (1/8), além dos 12 testes frontend focados. O executor aprovou **1911 testes de interface**, incluindo o novo controlo do manifesto, 132 unitários/611 asserções, estrutura 1/3106, tipos e compilação.
Novo backup/restauro: 195 tabelas, contagens/checksums coincidentes. Conferência posterior: **193 tabelas e 416 uploads inalterados**; diferenças apenas de cache e novas sessões, reconciliadas. Todas as 48 sessões anteriores preservadas, mais 11 novas; nenhuma sessão válida removida. Configuração protegida, permissões e designação jurídica anteriores mantidas. Mantêm-se 270 instituições, 12 lotes e 920.456 observações. Sem migrações ou publicações de conteúdo. Serviços activos e limites de autenticação conservados.
Recibos em `outputs/editorial-dashboard-500-20261114/destination-html-verified.json`, `destination-installation-verified.json`, `destination-runtime-reconciled.json`, `destination-ci-summary.json`, `destination-backup-summary.json`, `destination-restore-summary.json`, `http-build-test.log` e `reproduced-failure.log`. A anterior prova 302 sem sessão não fica apresentada como abertura HTML autenticada. **Erro do manifesto corrigido; instalação concluída.** A conferência visual da sessão do utilizador mantém-se distinta e pendente pelo conector indisponível. Não é necessária nova instalação para esta correcção.
## 14/11/2026 — correcção do erro 500 na abertura HTML do dashboard
Erro confirmado no registo de 14/11 às 10:00 UTC da revisão `73e1d7b9`: `Unable to locate file in Vite manifest: resources/js/pages/management/editorial-workflow.tsx`. O componente de página era também importado estaticamente por outras páginas para reutilizar os estados editoriais. O compilador gerou um chunk partilhado sem a chave de página exigida pelo `@vite` da resposta HTML. O login/302 e os dados JSON podiam funcionar; essas provas não demonstravam a abertura HTML autenticada.
Correcção delimitada: os mesmos estados passam para `resources/js/lib/editorial-states.ts`; as cinco páginas reutilizam esse módulo, sem importar estaticamente o dashboard. Sem mudança de rotas, permissões, configurações ou dados. Não se alterou o template global nem a segurança para contornar o erro.
Regressão reproduzida com o manifesto anterior. Com o build corrigido, o teste `EditorialDashboardBuildTest` usa o manifesto real e abre as duas páginas HTML autenticadas (visão geral e legislação), sem `withoutVite`: **1/8**. Os testes frontend afectados e o novo controlo de manifesto passaram (**12 casos**), com tipos/build aprovados. O controlo `editorial-dashboard-manifest.test.mjs` corre depois da compilação na bateria frontend obrigatória e impede promover uma candidata sem a entrada de página. Provas em `outputs/editorial-dashboard-500-20261114`.
Estado desta passagem: correcção em QA para reparar a entrega autorizada; instalação e conferência do build servido em curso pelo executor Git existente, com novo backup/restauro. Não declarar o 500 resolvido antes de conferir o manifesto e a geração HTML da revisão instalada. Conferência visual continua separada e pendente pelo conector indisponível.
## 14/11/2026 — menu Administração/Dashboard e apresentação instalados em 73e1d7b9
**Entrega autorizada concluída:** `73e1d7b96419873c6924e04e94f2a7374c18eb87`, confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Administração fica no menu principal para contas com destinos administrativos autorizados; Dashboard abre o submenu das actividades. A página `/management/editorial` apresenta os três grupos de cartões descritos abaixo, com fila e notificações preservadas. O painel operacional anterior, quando autorizado, fica também dentro de Dashboard. Visitantes mantêm os acessos públicos; não houve concessão de permissões.
Backup/restauro actualizado: **195 tabelas**, contagens e checksums coincidentes no restauro isolado; 417 entradas de ficheiros/configuração conferidas. O executor Git aprovou estrutura 1/3106, unitários 132/611, **1910 testes de interface**, tipos e compilação. A nova revisão foi promovida sem migrações ou alterações de configuração protegida.
Conferência final: **192 tabelas inalteradas** e **416 uploads com os mesmos bytes**. As três diferenças foram delimitadas: cache de apresentação/reinício de workers/limites de pedidos; mais uma resposta agregada de pesquisa, conservando as 21 linhas anteriores e os contadores monotónicos; limpeza de 16 sessões expiradas segundo a configuração existente de 120 minutos, sem remover sessões válidas, mais 12 novas sessões. Não se repuseram sessões expiradas nem se alterou a política de segurança. Mantêm-se **270 instituições**, **12 lotes**, **920.456 observações** e a designação jurídica anterior. Os endereços públicos responderam 200, a gestão conservou login e a API privada devolveu 401 sem sessão; serviços activos.
Recibos em `outputs/dashboard-navigation-20261114/destination-installation-verified.json`, `destination-runtime-reconciled.json`, `destination-navigation-summary.json`, `destination-ci-summary.json`, `destination-backup-summary.json`, `destination-restore-summary.json` e `installation-manifesto.json`. O pacote QA anterior mantém o hash; `outputs/recibo-menu-dashboard-73e1d7b9-20261114.zip` contém apenas os comprovativos sanitizados da instalação.
**Saldo:** instalação concluída. Resta a conferência visual autenticada, incluindo janela estreita, já identificada como pendente pela indisponibilidade do conector. Não é necessária nova preparação, reinstalação ou publicação de conteúdos. Os testes de renderização/compilação e a verificação dos destinos autorizados não substituem a conferência visual.
## 14/11/2026 — Administração principal e apresentação agrupada do dashboard em QA
Pedido expresso da Tempo: Administração fica no menu principal e Dashboard reúne as actividades num dropdown interno. Esta alteração posterior substitui, apenas para contas com destinos administrativos autorizados, a anterior opção de manter Administração dentro de Mais. Visitantes e contas sem esses destinos conservam os seis acessos públicos; não se apresenta um menu administrativo vazio.
O catálogo de navegação comum é reutilizado no cabeçalho e na navegação lateral. Administração contém Dashboard → Visão geral, Artigos, Legislação, Documentos, Oportunidades, Competitividade, Carteira, Demografia, Estruturas e relações, Conteúdos territoriais, Zonas e indicadores e Notificações, apenas conforme as autorizações e componentes existentes. O painel operacional anterior também passa para Dashboard quando a conta já tem esse destino autorizado. As restantes operações administrativas mantêm os seus destinos, sem duplicar ligações. O grupo activo acompanha a página actual. Em ecrãs pequenos os subgrupos expandem por toque/teclado, sem exigir abertura por hover.
A página `/management/editorial` passa a apresentar três grupos consistentes: **Conteúdos e oportunidades**, **Investimentos e competitividade**, **Território e dados**. Os acessos, contadores e estados existentes ficam alinhados em cartões, com adaptação de uma a três colunas, fila de conteúdos e notificações identificadas. Retiraram-se as ligações dispersas e o segundo bloco repetido de oportunidades. Guardas, filtros, decisões e histórico não mudam.
Provas em `outputs/dashboard-navigation-20261114`: 26 testes de navegação/apresentação, integração de avisos/acesso 4/32, análise estática, tipos e compilação aprovados. Testam-se ausência de administração para visitantes/contas sem âmbito, preservação de destinos, deduplicação, localização do grupo activo, dropdown desktop, expansão móvel, grupos/counters e omissão de áreas indisponíveis. Sem migrações, dados ou permissões alteradas. A conferência visual permanece pendente pelo bloqueio do conector já documentado; os testes de renderização não são apresentados como conferência visual.
**Estado:** implementado/verificado em QA, ainda sem instalação desta nova apresentação. A homologação mantém `a4541298`. Preparar uma única entrega pelo processo Git existente, com backup/restauro e autorização concreta; não reinstalar o complemento anterior nem republicar conteúdos.
## 14/11/2026 — complemento do dashboard instalado e activo em a4541298
**Instalação conjunta concluída**, após autorização expressa da Tempo. A revisão `a4541298c0a386ab8bfd8e3e531e12d432ec671b` está confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Utilização: `/management/editorial`, com login/MFA e os âmbitos existentes. Estruturas/relações/cadeias, revisão das zonas e indicadores documentais e notificações nativas estão activos. A designação jurídica nominal recebida foi aplicada à configuração protegida; a conta existe e tem correio verificado. Não se alteraram permissões gerais nem se publicaram conteúdos automaticamente.
Backup actualizado com **195 tabelas**, restauro isolado com contagens e checksums coincidentes, 417 entradas de ficheiros/configuração conferidas. As duas alterações aditivas e o contrato nominal foram instalados antes da entrega Git. O executor aprovou o ciclo de estrutura 1/3106, 132 testes unitários/611 asserções, **1906 testes de interface**, tipos, compilação e verificações de plataforma/implantação. O ramo `main` foi promovido pelo executor existente; não houve edição manual da pasta servida.
**Preservação final:** 192 tabelas com conteúdo inalterado, incluindo o conteúdo anterior das tabelas editoriais, e duas entradas nominais acrescentadas ao registo de migrações. As diferenças nas duas tabelas de funcionamento foram reconciliadas com o backup: cache apenas por reinício dos workers e limites de pedidos; os 33 identificadores de sessão anteriores preservados, sem alterar o seu conteúdo, e 11 novas sessões. Todos os **416 uploads** mantêm os bytes. Permanecem **270 instituições**, **12 lotes aprovados** e **920.456 observações**. Endereços públicos responderam 200; gestão conservou o redireccionamento de autenticação e a API privada devolveu 401 sem sessão. Nginx, PHP-FPM e fila de implantação activos.
Recibos: `outputs/editorial-completion-20261114/destination-installation-verified.json`, `destination-backup-summary.json`, `destination-restore-summary.json`, `destination-schema-summary.json`, `destination-framework-reconciled.json`, `destination-activation-summary.json`, `destination-ci-summary.json` e `installation-manifesto.json`. O pacote QA selado permanece intacto; `outputs/recibo-entrega-dashboard-a4541298-20261114.zip` acrescenta apenas os comprovativos sanitizados da instalação.
**Saldo deste complemento:** instalação e configuração jurídica deixam de ser pendências. Resta a conferência visual autenticada dos percursos e da janela estreita; o conector continua identificado como indisponível, sem reinícios repetidos ou alterações de segurança. Os testes de interface não substituem essa conferência. Não é necessária nova preparação ou reinstalação. O restante saldo funcional do portal mantém-se na mesma matriz, fora deste complemento do dashboard.
## 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.
Os cinco componentes estão activos: circuito comum de artigos/legislação/documentos e ligação às oportunidades nativas; Competitividade; carteira/dados-base/localização vinculados; novos lotes demográficos; perfis territoriais nativos. Entrada da equipa: `/management/editorial`; território: `/management/territorial/editorial`. Mantêm os perfis, âmbito, autenticação e MFA existentes. Não foram atribuídas contas, funções ou permissões nem publicados conteúdos automaticamente. **A validação jurídica de novas versões que a exijam continua dependente de designação nominal explícita; a lista de validadores permanece vazia.** Isto não retira conteúdos já publicados nem bloqueia os restantes percursos.
Backup consistente de 193 tabelas e 417 entradas de ficheiros/configuração em `/var/backups/investmoz-editorial-20261114/backup`, com hashes conferidos. Restauro isolado integral comparado por contagens e checksums, seguido do ensaio do SQL exacto; 416 ficheiros carregados preservados. O SQL aditivo e o contrato nominal foram instalados separadamente do executor Git, conforme a autorização; as quatro migrations no código continuam com guarda QA. Contas, concessões e conteúdos não foram alterados. Após existirem edições, não efectuar downgrade para código que ignore o histórico nem eliminar as estruturas: pausar o circuito preservando código compatível e decisões.
Preservação confirmada: 270 instituições publicadas, 12 lotes demográficos aprovados e 920 456 observações; os hashes das tabelas de negócio, territórios e autorizações conferidas mantêm-se. A diferença inicial nos rascunhos de simulação resultava de leitura sem actor sob RLS. Repetida a comparação com a identidade do backup, os 12 registos e o checksum coincidem; nenhuma alteração de permissões ou dados foi necessária.
O executor aprovou compilação, tipos, requisitos de plataforma, diagnóstico C132, teste de ciclo de schema (3 106 asserções), 132 testes unitários/611 asserções e 1 902 testes frontend. Consulta pública, login, instituições, demografia, Mineração e Energia responderam 200; gestão manteve redirecção para login e API privada recusou consulta anónima (401). A conferência usa o cliente identificado do executor existente; não foram desactivadas verificações de segurança. Estes controlos não substituem a conferência visual autenticada.
Recibos finais locais em `outputs/editorial-agrupado-20261114/destination-installation-verified.json`, `destination-ci-summary.json`, `destination-draft-identity-summary.json` e recibos de backup/restauro/schema/activação na mesma pasta. O pacote QA de 13/11 e o pacote autorizado de 14/11 permanecem intactos. **Instalação deixa de ser pendência destes recortes.** Saldo: conferência visual, designação jurídica nominal, outras estatísticas, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade/território. A instalação não encerra o portal integral nem esses resíduos.
## 14/11/2026 — revisão territorial privada e preservação da publicação
O registo territorial existente guarda agora as alterações numa revisão privada encriptada, sem mudar `geo_locations` nem o perfil publicado. Reutiliza as revisões e os recibos idempotentes nativos, sem nova tabela ou motor. O percurso é guardar proposta → submeter → iniciar revisão → aprovar → publicar. Aprovar não publica. O editor não pode rever nenhum conteúdo para o qual contribuiu; o publicador é distinto dos contribuidores e do revisor, conservando a separação nativa. Fontes oficiais já aceites não recebem nova exigência de autenticação.
A publicação confere a versão, o conteúdo, a fonte e a hierarquia, bloqueia as linhas de contexto e aplica a alteração numa transacção. Alterações posteriores exigem nova revisão; conflitos não sobrescrevem a proposta. Arquivar uma proposta conserva a publicação anterior. Retirar expressamente a publicação mantém a proposta e a revisão pendentes. O estado anterior fica no histórico, sem fabricar aprovação. Fontes e referências sem perfil territorial nativo não são adoptadas nem modificadas por este recorte.
Gestão: `/management/territorial/editorial`, ligada ao circuito editorial, com lista paginada, proposta, comparação com o nome/estado público, fonte, validade, fundamento e histórico. As APIs e pré-visualizações mantêm autenticação, gestão, MFA, resposta privada e permissões no servidor. As escritas exigem MFA fresco antes de repetir um recibo; desligar o circuito não permite contornar versões privadas existentes. O formulário conserva edições durante erros e impede mudar de registo ou decidir enquanto há alterações por guardar. As fontes disponíveis vêm do catálogo existente; a selecção mostra até 100 fontes, com aviso no limite. Criação de fontes e alterações de hierarquia continuam nas APIs nativas; o ecrã novo edita os conteúdos dos perfis existentes.
Provas em `outputs/editorial-territorial-20261114/`: cinco percursos novos/67 asserções, cinco testes nativos/44 asserções, dois testes de apresentação do novo ecrã; testes frontend afectados, PHPStan, tipos e compilação registados na mesma pasta. Dados sintéticos, transacções revertidas e bases QA isoladas em 55441. Conferência visual humana pendente; nenhuma instalação ou alteração operacional.
Saldo do pacote conjunto: Fase 1, Competitividade, fichas e dados-base/localização vinculados à carteira, novos lotes demográficos e perfis territoriais nativos implementados em QA. Permanecem outras estatísticas, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade/território. Estes resíduos não são encerrados pelo pacote. A instalação conjunta exige contrato operacional das quatro alterações aditivas de schema, backup/restauro e autorização concreta; as migrações actuais continuam limitadas ao QA. Não executar `migrate` indiscriminadamente nem enviar este ramo a `main` antes dessa preparação.
## 14/11/2026 — publicação editorial dos novos lotes demográficos
O circuito comum integra agora os novos lotes demográficos já importados para consulta privada: preparar revisão → submeter → iniciar revisão → aprovar → publicar. Aprovar a edição não altera o estado nativo `imported`; só a publicação explícita disponibiliza a série às análises existentes. As séries anteriormente `approved` conservam o estado e as observações, sem nova aprovação. Uma fonte nova continua a ser um lote próprio; não é substituída a série anterior. Arquivar uma publicação desactiva esse lote, conservando valores, originais e eventos.
As decisões vinculam o SHA256 do original e da prévia, a proveniência, advertências, correspondências territoriais e a integridade de todas as observações. A impressão digital usa SHA256 dos registos em agregados particionados no PostgreSQL; o bloqueio de publicação percorre os registos em blocos, sem carregar a série inteira na memória PHP. Uma alteração nos originais, observações ou proveniência é recusada, inclusive ao tentar legitimá-la como nova revisão. Uma alteração do contexto territorial sinaliza revisão e permite nova edição sem alterar os números. Não foi criada outra tabela de observações, nem reexecutado o preparador dos exemplares aceites.
A gestão nativa liga à revisão comum e ao download protegido do original. O painel/fila mostra pendências e notificações apenas aos operadores com a autoridade existente. A aprovação independente é configurável por `demography`, sem inventar novas designações; as provas usam actores sintéticos separados. Escritas exigem MFA válido na sessão. A operação antiga de disponibilização não contorna o circuito, mesmo após desactivá-lo. Publicações versionadas são retiradas pelo arquivo explícito da edição. A resposta vazia de uma operação é tratada sem expor `JSON.parse` nem confirmar um resultado desconhecido.
Migração `2026_11_13_233000_extend_editorial_editions_to_demography` instalada apenas na base própria QA 55441: acrescenta a associação do lote à edição comum e mantém a exclusividade do sujeito. Recibo `outputs/editorial-demography-20261114/qa-installation.json`: contagens das 177 tabelas anteriores preservadas, excepto o livro de migrações, sem preenchimento retroactivo ou alteração de contas/permissões. Reversão recusa remover histórico.
Provas no mesmo directório: seis casos iniciais aprovados, incluindo o ensaio de **131400 observações com limite PHP de 256 MB** (`integration.log`); cinco casos funcionais reforçados aprovados/66 asserções (`integration-final.log`); regressão demográfica anterior 14/52; regressão Fase 1 11/223, carteira 5/199 e dados-base 6/178. Frontend 24 testes, PHPStan, tipos e compilação aprovados. As observações do ensaio grande são sintéticas, não dados INE reais; todos os dados de prova são revertidos por transacção.
Saldo: novos lotes têm circuito de publicação separado demonstrado em QA e integram a candidata agrupada. Sem instalação operacional, publicação real ou alterações aos doze lotes anteriormente disponibilizados. A entrega passa a incluir quatro migrações QA do circuito comum; o contrato/schema operacional, a autorização concreta e a instalação conjunta continuam pendentes. Conferência visual pendente. Outras estatísticas, registo territorial, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade não são encerrados por este recorte.
## 13/11/2026 — dados-base e localização ligados à mesma edição da carteira
O recorte de carteira inclui agora os dados-base de projectos, clusters e corredores com ficha existente. Nome, código, estado operacional, descrição/sector, orçamento e datas são guardados como alterações propostas na mesma edição privada. A localização reutiliza a selecção/hierarquia territorial existente e fica igualmente proposta, com contador próprio e contexto territorial por hash. A gestão recupera os campos deliberados; a consulta pública continua a ler os valores anteriores. Publicar a edição aprovada aplica ficha, dados-base e localização numa única transacção, reutilizando as auditorias de gestão e território.
Conflitos de edição ou de código não sobrescrevem dados. Um código ocupado depois da aprovação devolve 409 e reverte ficha/dados-base em conjunto. Alterações da referência territorial invalidam a decisão; a publicação bloqueia as linhas de contexto durante a reconferência. Todos os contributos privados continuam sujeitos à independência da carteira. Desactivar o circuito ou arquivar a ficha não permite contornar a revisão. Ao iniciar outro ciclo depois de publicar/arquivar, alterações propostas antigas não são reaplicadas silenciosamente. Cadastros internos sem ficha associada conservam o comportamento anterior: este recorte não encerra o versionamento de todas as estruturas nem das suas relações/cadeias de valor.
Provas em `outputs/editorial-structures-20261113/`: 6 testes novos/178 asserções; carteira 5/199; regressão de estruturas/território 19/289; Fase 1 11/223; frontend 21 testes. PHPStan, tipos e compilação aprovados. Nenhuma nova migração deste recorte, nenhuma alteração de contas/permissões ou instalação operacional. Alterações integradas na candidata agrupada; estatística, indicadores demográficos e conteúdos territoriais continuam no saldo da Fase 2, assim como as notificações próprias de Competitividade e as estruturas sem ficha. Conferência visual pendente.
## 13/11/2026 — fichas da carteira integradas no mesmo circuito e pacote editorial
**Lacuna corrigida em QA:** guardar uma ficha de projecto âncora, cluster ou corredor deixava de a disponibilizar ao público. Com `PORTAL_EDITORIAL_PORTFOLIO_ENABLED` activo, o editor existente guarda uma edição privada e conserva integralmente o registo público, incluindo título, conteúdo, fonte, versão e datas. A confirmação independente continua obrigatória; aprovar e publicar são operações distintas. A publicação anterior só é substituída pela publicação explícita da edição aprovada. Arquivar uma edição privada preserva-a; arquivar a edição pública actual retira-a da consulta, sem apagar dados ou históricos.
**Reutilização:** mesmas tabelas, decisões, transições, auditoria, responsáveis, notificações e ecrã do circuito Fase 1. A migração aditiva `2026_11_13_230000_extend_editorial_editions_to_portfolio` permite que uma edição pertença a um recurso documental OU a uma ficha da carteira, com chaves estrangeiras, unicidade da revisão e exclusividade do sujeito. Não foi criado outro motor nem um segundo dossier. A estrutura foi instalada exclusivamente em `editorial_phase1_20261113_test:55441`; recibo em `outputs/editorial-portfolio-20261113/qa-installation.json`, com as contagens das 177 tabelas anteriores preservadas, excepto o incremento do livro de migrações. A reversão recusa remover histórico da carteira existente.
**Percurso:** Gestão de conteúdos → Carteira → abrir ficha → guardar alteração → «Conferir versões, submeter, aprovar e publicar no circuito editorial». O ecrã mostra a edição exacta, a fonte e o contexto identificado, conteúdo, versões, decisão e histórico. Submeter com responsável, iniciar revisão, aprovar e publicar separadamente. O painel indica as fichas pendentes; as notificações ligam à edição concreta. Os gestores usam as autorizações existentes; todas as escritas exigem MFA válido. API/JSON e prévias verificam a autoridade no servidor. Leitura privada usa `private, no-store`; a gestão da carteira reutiliza também o histórico Inertia cifrado.
**Integridade:** o hash liga a edição ao seu conteúdo e contexto de projecto/cluster/corredor, incluindo dados relevantes da entidade. Alterações do contexto ou da edição exigem nova aprovação. Todos os colaboradores privados são excluídos da aprovação e publicação dessa edição, preservando a independência já exigida na carteira. A publicação reconfere a autoridade do aprovador. Tentativas pela antiga operação combinada são recusadas. Desactivar o módulo não permite contornar o circuito para editar fichas com histórico novo. Um contexto desactualizado impede aprovar/publicar, mas permite devolver/rejeitar para correcção, sem prender a revisão. Versões anteriores, razões e dados deliberados ficam conservados.
**Provas:** 5 testes novos/199 asserções; regressão Fase 1 11/223; carteira anterior 8/201; notificações nativas de oportunidades 1/20; frontend 20 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-portfolio-20261113/`, incluindo `integration-final.log`, `phase1-regression-final.log`, `native-regression.log`, `opportunity-notices-regression.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log` e `build.log`. Os testes de carteira anteriores usam a base nativa própria e descartável; os novos usam transacções revertidas. Não foram submetidos processos reais, alteradas permissões ou publicados conteúdos reais.
**Entrega e saldo:** este avanço integra a candidata agrupada, preservando os pacotes anteriores. A instalação operacional continua por executar e inclui agora três migrações da Fase 1/carteira; as migrações só autorizam QA e o executor Git não executa SQL no destino. É necessário preparar/autorizar a aplicação nominal do schema e contrato, backup/restauro e instalação conjunta pelo processo existente. A conferência visual permanece pendente, sem invalidar as provas aceites. Este recorte versiona as fichas públicas da carteira; a edição dos dados-base de projectos/clusters, relações, estatística, indicadores demográficos e conteúdos territoriais ainda não fica encerrada, nem as notificações próprias de Competitividade. Nenhuma instalação ou activação em homologação/produção foi efectuada.
## 13/11/2026 — pacote agrupado do Dashboard Colaborativo e revisão de Competitividade
**Decisão do proprietário:** agrupar melhorias e funcionalidades relacionadas para uma instalação conjunta quando o conjunto estiver pronto. O pacote desta passagem reúne a Fase 1 e o primeiro recorte editorial da Fase 2: análise de Competitividade dos clusters. Não representa o fecho de toda a Fase 2 nem autorização para publicar conteúdos.
**Disponível em desenvolvimento/QA:** o editor existente dos clusters permite guardar → submeter → iniciar revisão → aprovar → publicar, com devolução, rejeição, arquivo e recuperação do histórico. A aprovação deixa de publicar quando `PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED` estiver activo. Uma análise anteriormente pública continua disponível ao activar o novo circuito, guardar, rever, recuperar ou arquivar um rascunho. Publicar substitui-a explicitamente; retirar a publicação não cancela uma revisão privada em curso. Estados editoriais e estado operacional do cluster aparecem separados na lista existente, acessível pela Gestão de conteúdos/Circuito editorial.
**Versões e autoridade:** o hash vincula o conteúdo das cinco forças e o contexto identificado do cluster (nome, código, sector, estado e território). Alterações posteriores exigem guardar nova edição e obter nova aprovação. Conflitos devolvem 409 sem sobrescrever texto. A política independente já configurável aceita o tipo `cluster_competitiveness` e impede aprovação por qualquer colaborador da edição quando exigida. O publicador reconfere a autoridade actual do aprovador. As escritas verificam gestão existente, exclusão de auditor e MFA da sessão no servidor. O histórico continua no mecanismo cifrado e aditivo existente; não há migração nem novo motor de Competitividade. A exposição pública conserva apenas análise publicada, versão e data, sem decisões privadas/identidades. Desactivar o circuito conserva a publicação e impede retomar por uma operação legada que contorne as decisões.
**Provas aceites:** 14 testes de integração de clusters/252 asserções, incluindo os sete testes anteriores; regressão Fase 1 11/223; frontend 18 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-clusters-20261113/`. As provas anteriores de oportunidades e anexos são reutilizadas, sem repetição. Dados sintéticos em transacções revertidas na base própria de QA 55441; sem alteração operacional, novas concessões ou publicação real.
**Pacote/instalação:** bundle Git conjunto a partir da base `2f1ef2fd`, manifesto com hashes e provas, preservando o bundle anterior. O executor continua a ser `deploy/homologacao/pipeline.py`; não foi alterado. Antes de instalar: confirmar revisão/base e delta, backup recuperável e ensaio de restauro isolado; tratar expressamente as duas migrações aditivas da Fase 1 e o contrato nominal de schema do destino. O executor Git não executa SQL no destino e as migrações actuais só autorizam QA: não basta enviar este pacote a `main`. Após instalação autorizada, conferir a revisão efectivamente servida, acessos e os percursos afectados, sem republicar conteúdo. Reversão pelo executor existente preserva dados; não usar rollback destrutivo de tabelas com histórico. Não foi obtido novo backup remoto nem executada instalação nesta passagem.
**Saldo concreto:** Competitividade tem agora aprovação/publicação separadas demonstradas em QA. Conferência visual, preparação operacional do schema/contrato, configuração explícita de designações e instalação conjunta continuam pendentes. Versionamento editorial dos dados-base de projectos âncora/clusters, estatísticas, indicadores demográficos e conteúdos territoriais continua por implementar; notificações editoriais dos clusters não são encerradas por este recorte. A Fase 1 continua identificada separadamente no mesmo saldo. Sem alterações a dados demográficos, instituições ou restantes entregas aceites.
## 13/11/2026 — Dashboard Colaborativo, Fase 1 implementada em desenvolvimento/QA
**Âmbito:** artigos, legislação, documentos e integração do circuito existente de oportunidades. A versão pública de um artigo permanece integralmente inalterada ao guardar ou rever uma nova edição privada, incluindo metadados, identificador e contador públicos. Não houve intervenção na homologação ou produção nesta passagem.
**Utilização em QA:** depois de instalar as duas migrações aditivas em base isolada e activar `PORTAL_EDITORIAL_WORKFLOW_ENABLED`, entrar pelo Painel/Gestão de conteúdos → Circuito editorial (`/management/editorial`). Alterar no editor existente, guardar a edição, conferir conteúdo/anexos e submeter com fundamento e responsável. O revisor inicia a revisão, valida juridicamente quando exigido e aprova. Publicar é uma segunda operação explícita. Devolução, rejeição e arquivo exigem fundamento. Arquivar um rascunho preserva a publicação anterior; arquivar a edição pública actual retira-a explicitamente. A fila permite filtrar tipo/estado e consultar versões, responsáveis e histórico. As notificações internas ligam à edição concreta ou à oportunidade no circuito nativo e podem ser marcadas como lidas.
**Autorizações e versões:** verificações no servidor para HTML, JSON, prévias e anexos; MFA existente nas escritas; escopos e permissões existentes, incluindo revogação de acesso. Aprovação e validação jurídica vinculadas à edição, conteúdo e versões/hashes dos anexos. Alterações ou bytes diferentes impedem reaproveitar a decisão. Conflitos de edição devolvem 409. Auto-aprovação bloqueada quando a política independente a exige, abrangendo todos os colaboradores da edição. A configuração `editorial_workflow.independent_approval` distingue a obrigação de independência do campo legado que apenas regista a revisão efectuada. A designação jurídica em `legal_reviewers` é explícita; não foram criadas contas, perfis ou concessões. A fonte oficial já aceite não é novamente auditada por este circuito.
**Oportunidades:** reutilizam filas, atribuições, revisões, publicador independente, prévias e histórico existentes. Novas submissões com a Fase 1 activa vinculam a validação jurídica à versão exacta e à versão dos anexos. Submissões já aprovadas mantêm o contrato/hash anterior. Notificações próprias e resumo de pendências entram no painel sem conceder acesso a outra organização ou conteúdo. Não existe um segundo motor de oportunidades.
**Verificação:** testes de integração do novo circuito, 11 aprovados/223 asserções; regressão e aceitação nativa de oportunidades, 6 aprovados/116 asserções, mais o caso reforçado de notificações isolado, 1 aprovado/20 asserções; unidade jurídica, 1 aprovado/6 asserções; frontend, 12 aprovados. PHPStan focado e tipos/compilação aprovados. A tentativa inicial nativa sem activos de compilação e com armazenamento de fixtures partilhado foi descartada; as provas aceites usam activos e armazenamento QA separados. Provas locais: `outputs/editorial-phase1-20261113/` (`integration-final.log`, `opportunity-acceptance.log`, `opportunity-notices.log`, `legal-unit.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log`, `build-delivery.log`).
**Estrutura e preservação:** base própria `editorial_phase1_20261113_test`, PostgreSQL de QA 55441. Primeira migração criou duas tabelas vazias; segunda acrescentou o vínculo de notificações às oportunidades. Recibos `qa-installation.json` e `qa-notification-installation.json`: contagens das 175/177 tabelas preexistentes preservadas em cada etapa, excepto incremento documentado do livro de migrações. Sem preenchimento retroactivo, publicação automática, alteração de contas/permissões, nem escrita em bases operacionais. Reversão das migrações recusa apagar histórico existente; desactivar o novo módulo preserva dados e exige planear qualquer retoma de edição legada.
**Saldo:** implementado e demonstrado por testes em QA; conferência visual no navegador pendente. Activação operacional, designações jurídicas/independência efectivas e instalação fora do QA não executadas, dependem de decisão concreta. As migrações desta fase recusam destinos operacionais. Fase 2 (projectos âncora, clusters, estatística, demografia e conteúdos territoriais) continua delimitada para trabalho posterior; não é encerrada por esta entrega. Preservadas as entregas anteriores e o trabalho paralelo na pasta original.
## 13/11/2026 — pesquisa rápida: intenções e categorias, implementada em QA
A pesquisa reconhece contagens do catálogo publicado (por exemplo, «quantos corredores existem»), sem misturar projectos ou oportunidades apenas por mencionarem corredores. Zero registos e indisponibilidade de uma fonte têm apresentação distinta. «Como licenciar uma empresa» conduz ao roteiro existente e à preparação de S08 no dossier, sem inventar requisitos oficiais.
Produtos físicos e financeiros aparecem em categorias separadas, com opções Todos / Produtos físicos / Produtos financeiros e preservação do filtro e paginação. Exemplos: «tipo de produtos e sua localização», «onde encontrar grafite em Cabo Delgado» e «produtos financeiros em Nampula». Reutiliza as referências aceites de Mineração/Energia, oportunidades agrícolas publicadas e versões publicadas das instituições. A ligação mineral conserva grupo/local seleccionado; a ligação financeira abre a instituição existente. Presença física e cobertura financeira aparecem separadas. Rascunhos e publicações futuras não são divulgados.
Limites: localização nestes exemplos é correspondência textual aos dados documentados; não representa geocodificação, elegibilidade ou garantia de oferta/financiamento. Só entram conteúdos publicados das fontes efectivamente disponíveis. Não é uma resposta aberta por IA. Erros 404/503 de fontes são identificados sem ocultar os outros resultados; 403 continua recusado.
Verificação focada em QA isolado 55441: integração 4 testes/118 asserções; regressão federada 3 aprovados e 1 ignorado por guarda de ambiente; unidade 8 testes/39 asserções; frontend 7 testes. Tipos TypeScript, PHPStan e compilação aprovados. Provas em `outputs/quick-search-20261113/`. Sem migrações, alterações de dados ou permissões. Conferência visual no navegador e instalação desta candidata ainda pendentes. Preservar as entregas e os doze lotes demográficos já aceites.
## 13/11/2026 — erro 500 corrigido e verificado no PHP web, revisão 4988ca89
A recusa de acesso anterior estava corrigida, mas a abertura do instalador revelou outra incompatibilidade: o PHP-FPM bloqueia `proc_open`; a criação de `Symfony Process` na pré-visualização provocava 500. Os registos de 13:49:58 e 13:50:10 identificaram esta causa. Conferência de leitura confirmou que não existiam tabelas demográficas nem recibos de importação no destino.
@@ -9771,3 +9960,172 @@ Entrega autorizada pelo proprietário e instalada pelo executor Git em `https://
Backup/restauro fresco:193 tabelas e381ficheiros. Conferência pós-entrega:12ficheiros de fonte por SHA256,HTTPS e controlos de entrada privada,12tabelas de negócio/geometria sem alteração,mais os três lotes demográficos presentes no backup conservados por contagens/checksum de metadados,observações e eventos. Durante a entrega foram concluídas,por operações autenticadas independentes,as restantes incorporações demográficas:a verificação final encontrou12lotes aprovados/920456observações. Esta entrega de relatórios não fez importações ou migrações;actualizou o vínculo da readiness à nova revisão,preservando as preparações CLI aceites.
Prova final `outputs/dossier-reports-20261113/homologation-installed.json`;estado agregado `verification.json`. QA funcional,privacidade,recuperação,exportações,tipos,lint,análise PHP e compilação aprovados. A conferência visual no uso normal permanece pendente,sem reabrir a instalação nem as provas aceites. Este estado substitui a indicação histórica de QA sem instalação acima e não declara concluídos outros módulos ou relatórios ainda por implementar.
## 13/11/2026 — consulta e descarga de resumos das 19 etapas, correcção em QA
Conferência com conta sintética e MFA normal em `http://127.0.0.1:18814/investimentos/5245f06c-bdbd-4caf-bc37-900f10bf582c`, base isolada `investmoz_demography_test:55441`. Novo dossier, versão 21: os serviços existentes guardaram fundamentos sintéticos em S01–S19. Não foram alterados dossiers anteriores nem simuladas decisões/licenças externas. Na homologação foi criado apenas o dossier separado de ensaio `db85f087-0737-4164-892a-4fa4fe1313df`, com fundamento sintético S01; os três dossiers anteriores foram preservados.
Lacuna corrigida: o componente existente passa a permitir seleccionar a etapa, consultar o resumo dos registos recuperados e preparar Word ou HTML. A ligação de descarga permanece visível com indicação de ficheiro pronto; o conteúdo e as pendências não são confundidos com documentos oficiais em falta. As limitações do modelo passam a integrar ambos os formatos. Mudar de dossier, versão ou oportunidade reinicializa a selecção/ligação local para não reutilizar uma exportação de outro contexto.
Prova integrada: consulta das 19 etapas e 38 descargas reais pelo Edge (19 Word/19 HTML), conferidas em disco por identidade, etapa, versão, fundamento, limitações e ausência de registos de outra etapa. Actualizar a página recuperou os 19 registos e a versão 21. Evidência em `outputs/dossier-reports-20261113/stage-authenticated-browser-checks.json`, `stage-authenticated-download-verification.json`, `stage-recovery.json` e `visual/04-dossier-qa-S19-descarga.jpg`. Os ensaios isolados do componente estão identificados separadamente, sem os apresentar como prova de persistência.
Validação nova, limitada ao delta: dois testes frontend (incluem exportação das 19 etapas e escape das limitações), tipos, lint dos três ficheiros de produto afectados e compilação QA. Não foram repetidas as provas aceites de APIEX/SWOT, cálculo fiscal, permissões ou instalação. O perfil antigo de QA referia contas que já não existem no cluster actualmente ligado; foi localizado e usado o perfil documentado da demografia, sem criar permissões ou reiniciar o cluster. A serialização da chave MFA da conta recém-criada foi corrigida apenas nessa conta sintética.
Estado: correcção implementada e conferida em QA, ainda sem instalação. A homologação mantém `07182c7c` para o recorte de relatórios anteriormente instalado. A prova demonstra resumos de preparação por etapa; não declara produzidos estudos financeiros, licenças, decisões, pacotes APIEX ou documentos que não existam nos respectivos percursos. A instalação deste novo delta é a pendência de entrega; os saldos próprios dos outros módulos mantêm-se.
## 13/11/2026 — resumos por etapa instalados na homologação em 8854b292
Autorização expressa do proprietário recebida e executada. Revisão servida `8854b2928c6907b6742a1eb622ae70e6b6a9e998`, construída em 2026-11-13T13:16:06Z; troca concluída em 13:16:09Z. Entrada habitual: `https://teste-investidor.tempo.co.mz/investimentos`, abrir o dossier e a secção de relatórios, seleccionar a etapa, consultar o resumo, preparar Word ou HTML e descarregar o ficheiro pronto. O relatório consolidado permanece disponível.
Backup/restauro fresco: 193 tabelas/409 ficheiros, conferidos por contagens e hashes. Verificação pós-entrega: quatro ficheiros por SHA256; 18 tabelas protegidas inalteradas, incluindo instituições, dossiers, rascunhos, documentos, jornadas, etapas e as três tabelas demográficas. Mantidos 12 lotes aprovados/920456 observações. Sem migrações, importações, publicação ou alterações de permissões. Preparação demográfica e os controlos autenticados foram preservados e a readiness foi vinculada à revisão instalada, mantendo o exemplar anterior arquivado.
O executor Git existente e os seus controlos obrigatórios de CI foram reutilizados sem alteração de fonte ou da configuração partilhada. A entrega ficou fixada na branch aprovada `entrega/relatorios-etapas-20261113`, pois a main já continha alterações alheias de pesquisa/suporte com integração falhada. Essas alterações e a main não foram substituídas nem instaladas por esta autorização.
Prova pós-instalação no Edge, na sessão autorizada já existente: dossier sintético `db85f087-0737-4164-892a-4fa4fe1313df`, resumo S01 e fundamento guardado recuperados; Word e HTML efectivamente descarregados e conferidos. Nenhum dossier foi alterado nesta conferência. Não se repetiram as 19 consultas/38 descargas aceites em QA; o CI obrigatório do executor correu normalmente. Recibo: `outputs/dossier-reports-20261113/stage-homologation-installed.json`; captura `visual/06-homologacao-S01-descarga.jpg` no mesmo directório.
Estado deste delta: instalado e conferido. Substitui a pendência de instalação do registo anterior, preservando os seus limites: são resumos de preparação dos registos disponíveis; não se declaram produzidas licenças, decisões, estudos ou outros originais ainda em falta nos respectivos percursos. O pacote QA selado `outputs/entrega-relatorios-etapas-20261113.zip` permanece inalterado; o recibo posterior comprova a instalação.
## 13/11/2026 — ACT-02: confronto de observações quantitativas, QA
Primeiro recorte executável da continuidade documentada de ACT-20261029-02: a comparação das observações públicas já recebidas no mapa. Reutiliza `InvestmentMapIndicators` e os filtros existentes; não acrescenta API, cadastro, publicação, fonte externa ou acesso a dados privados. Duas selecções progressivas mostram território, descrição/natureza da grandeza, indicador, valor textual exacto, unidade, período, âmbito, população/base, metodologia, situação, fonte/data e versão.
A conferência assinala diferenças de território/indicador/unidade/âmbito/base/metodologia, campos em falta (incluindo descrição/natureza), datas inválidas, escolha repetida e informação desconhecida/em divergência. Metadados alinhados não são certificação de comparabilidade. Duração, sobreposição dos períodos e natureza prevista/efectiva exigem leitura da fonte. Não soma, calcula evolução, converte grandezas, cria pontuação ou índice regional. Zero, precisão decimal e fontes originais são preservados.
Selecções identificadas por território, observação e versão. Fora dos filtros deixam de entrar na comparação; mudar a versão exige nova selecção. Conferência no Edge com três observações inteiramente sintéticas, sem API/escritas na base, exercitando o componente real compilado. Capturas e recibo em `outputs/map-comparison-20261113/`. Três testes novos dirigidos ao delta, tipos, lint e compilação QA aprovados; os testes aceites dos filtros anteriores não foram repetidos.
Estado: implementado e conferido em QA, sem instalação. V2-07/V2-10 e ACT-02 permanecem parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia de potencial económico continuam com o seu saldo próprio. Este incremento encerra apenas a ferramenta de confronto das observações recebidas. Não altera dossiers, relatórios de 8854b292, pesquisa ou suporte aceites. A candidata incorpora também o recibo documental de 8854b292, sem repetir a instalação ou as provas dessa entrega.
## 13/11/2026 — ACT-02: comparação de observações instalada em 1263b4f6
Instalação expressamente autorizada e concluída pelo executor Git existente na main, em `https://teste-investidor.tempo.co.mz`. A revisão servida é `1263b4f6b7609c3d4d293c2b9f8a68abb9d3e3c4`. No mapa das oportunidades, o bloco de indicadores apresenta «Comparar duas observações» quando há pelo menos duas observações públicas nos filtros actuais. A comparação mantém valores, descrições, unidades, períodos, âmbito, base, metodologia, situação, fontes e versões; não infere evolução, conversão, elegibilidade ou potencial regional.
Backup/restauro fresco de 193 tabelas/410 ficheiros, com contagens e hashes conferidos; verificação pós-entrega de 21 tabelas protegidas sem diferenças, incluindo dados territoriais, instituições, dossiers, jornadas, etapas, documentos, permissões e demografia. Mantidos 12 lotes aprovados/920456 observações. Fontes entregues verificadas por SHA256; configuração/fonte do executor preservadas. Sem migrações, importações, publicação de conteúdo ou novas permissões. A readiness demográfica foi vinculada à revisão, com o exemplar anterior arquivado.
Reutilizadas as provas aceites: três testes novos e a conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/map-comparison-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia económica. Esta entrega fecha a ferramenta de confronto das observações recebidas, não esses requisitos amplos.
## 13/11/2026 — ACT-02: natureza estruturada das observações, QA
Continuidade do confronto instalado em 1263b4f6. O editor existente permite declarar segundo a fonte se o valor é observado/efectivo, planeado/meta, projectado/estimado ou por confirmar. A classificação segue o envelope privado, a recuperação e o fluxo editorial existentes; preparar uma alteração não modifica a versão pública. Omissão por clientes anteriores preserva uma escolha deliberada. Sem indicação anterior, a consulta apresenta por confirmar; não infere natureza pela descrição nem reescreve registos. Fonte, valor textual exacto, zero, identidade, versão, confidencialidade e histórico mantidos. Não há migrações, novas permissões, publicação automática ou importação.
A consulta e a comparação mostram a natureza separada da situação documental; naturezas diferentes ou desconhecidas exigem conferência. Metadados alinhados não provam comparabilidade estatística. QA: três testes backend/21 asserções, cinco testes frontend afectados, análise PHP, Pint, tipos, lint e compilação aprovados. Edge: componentes reais compilados com dados sintéticos; selector e edição preservados, observado/projectado distintos, legado por confirmar, zero/precisão mantidos. Provas em outputs/observation-nature-20261113/. Reutilizadas as provas aceites dos restantes percursos.
Estado: implementado em QA, instalação pendente de autorização concreta; homologação mantém 1263b4f6. Encerra a capacidade estruturada de declarar a natureza, não a classificação factual de todo o acervo. ACT-02/V2-07/V2-10 continuam parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, conferência documental dos valores antigos e metodologia de potencial económico. Relatórios, pesquisa, suporte e entregas aceites preservados.
## 13/11/2026 — ACT-02: natureza das observações instalada em 42b9324a
Instalação expressamente autorizada e concluída pelo executor Git existente na main, em `https://teste-investidor.tempo.co.mz`. A revisão servida é `42b9324acbf0a15418424e8a539a0740932b2377`. O editor existente permite declarar observado/efectivo, planeado/meta, projectado/estimado ou por confirmar; a escolha segue a preparação privada e o fluxo editorial existente. Consulta e comparação distinguem natureza e situação documental, sem inferir a classificação dos registos antigos ou reescrever o acervo.
Backup/restauro fresco de 193 tabelas/411 ficheiros, com contagens e hashes conferidos; verificação pós-entrega de 21 tabelas protegidas sem diferenças, incluindo dados territoriais, instituições, dossiers, jornadas, etapas, documentos, permissões e demografia. Mantidos 12 lotes aprovados/920456 observações. Fontes entregues verificadas por SHA256; configuração/fonte do executor preservadas. Sem migrações, importações, publicação de conteúdo ou novas permissões. A readiness demográfica foi vinculada à revisão, com o exemplar anterior arquivado.
Reutilizadas as provas aceites: três testes backend/21 asserções, cinco frontend afectados, análise/tipos/lint/build e conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/observation-nature-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, classificação factual do legado e metodologia económica. Esta entrega fecha a capacidade de declarar/recuperar/consultar a natureza das observações, não esses requisitos amplos.
## 13/11/2026 — ACT-02: conferência temporal da comparação, QA
Continuidade após a natureza das observações instalada em 42b9324a. A comparação existente mostra a duração dos períodos de referência em dias de calendário, com ambos os limites incluídos. Durações diferentes exigem conferência; períodos distintos com sobreposição, incluindo um único dia comum, mostram aviso próprio. Datas incompletas, inválidas ou invertidas continuam por confirmar, sem duração inferida da data da fonte. Não anualiza, calcula taxas/evolução, soma observações ou declara comparabilidade estatística. Valores textuais, zeros, fontes, versões, filtros e âmbito territorial preservados.
QA: dois testes novos de duração/ano bissexto/sobreposição/limites e datas inválidas; sete testes afectados passaram. Tipos, lint e compilação aprovados. Edge com componente real compilado e dados sintéticos: duração inclusiva de 366/365 dias e aviso de sobreposição de um dia conferidos. Sem API, base de dados, migração, importação ou publicação. Provas: outputs/observation-period-20261113/. Provas aceites dos restantes percursos reutilizadas.
Estado: implementado em QA, sem instalação. ACT-02 permanece parcial: cobertura factual, classificação documental do legado, séries estatisticamente comparáveis, oferta laboral e metodologia económica conservam o saldo próprio. Esta entrega encerra a conferência automática de duração e sobreposição das datas recebidas, não a comparabilidade factual das séries.
## 13/11/2026 — ACT-02: comparação temporal instalada em 2bad72c0
Instalação autorizada e concluída na homologação https://teste-investidor.tempo.co.mz pelo executor Git existente. Revisão servida `2bad72c042f1a779849bfacc8a73ccd842b27fe7`. A comparação de observações mostra duração em dias com limites incluídos e assinala durações diferentes e sobreposição; não infere taxas, evolução ou comparabilidade estatística.
Backup/restauro fresco de193tabelas/412ficheiros;15fontes verificadas por hash;21tabelas protegidas sem diferenças. Mantidos12lotes/920456observações demográficas, instituições, dossiers, relatórios, pesquisa, suporte e permissões. Sem migrações/importações ou publicação de dados. Readiness demográfica vinculada à revisão com original arquivado. Configuração e fonte do executor inalteradas. HTTPS e entrada privada conferidos. CI obrigatório executado normalmente; provas anteriores reutilizadas sem nova ronda manual. Recibo: outputs/observation-period-20261113/homologation-installed.json; pacote QA selado preservado.
Este delta deixa de ser pendência de instalação; ACT-02 continua parcial quanto à cobertura factual, classificação do legado, oferta laboral, séries estatisticamente comparáveis e metodologia económica. A ferramenta não cria séries factuais por si.
## 13/11/2026 — lote PEC ordinário: último montante e PPC acima da base, QA
Conforme a directiva de agrupar melhorias relacionadas, este lote reúne dois resíduos do percurso de viabilidade fiscal, sem instalação por cada correcção. Reutiliza a preparação PEC, livro anual, rascunho privado, histórico e relatório do mesmo dossier; não cria um motor ou módulo paralelo. Recorte:2026, residente geral comercial/industrial/agrícola, ano civil, MZN. Homologação mantém2bad72c0.
1. Sem volume anterior, permite indicar o último montante PEC já determinado com NUIT, exercício, valor, fonte/fundamento e confirmação de que é o último aplicável ao caso. A hipótese documental29(5) conserva esse montante; não o confunde com volume de negócios ou pagamentos somados, não aplica de novo0,5%/limites nem deduz PPC pela segunda vez. Ausência, incompatibilidade, exercício futuro/anterior ao início ou falta de conferência mantêm a estimativa indeterminada. A referência fica conservada quando deixa de ser usada; alterações exigem reconferência. Uma rectificação documental para volume zero usa a mesma referência. Isto implementa a hipótese documentada, não uma inferência automática da qualificação fiscal ou do último montante a partir de dados inexistentes.
2. No cálculo ordinário29(2), PPC documentados acima da base limitada produzem estimativa PEC zero e diferença documental explícita. Não criam valor negativo, reembolso, crédito, transporte ou dedução anual. Esta regra tem marcador de versão na chave do livro apenas quando afecta o caso; obriga a recalcular resultados antigos dependentes, preservando histórico e casos ordinários anteriores. Fonte reutilizada:BR16/I16-04-2008,p122/PDF24,original SHA256fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee.
Os campos têm rótulos e controlos separados de largura completa; referências vazias aparecem por confirmar. Consulta do suporte e apresentação dos efeitos são parte do mesmo formulário. Não executa pagamentos ou declarações. Fonte, escolhas, original e histórico conservados no rascunho e no relatório.
QA:21testes frontend afectados, incluindo seis novos; uma prova nativa/42asserções de POST/PUT/GET em transacção com rollback na base isolada investmoz_demography_test:55441. Quatro cenários:último montante42500,25, fonte ausente, volume positivo com referência conservada e PPC60000/base50000/estimativa0/diferença10000. Recuperação e relatório financeiro do dossier conferidos; saldo anual138MZN e histórico anterior inalterados. Tipos, lint focado, sintaxe PHP, Pint e build QA aprovados. Edge:componentes reais compilados com dados sintéticos, ambos os efeitos, confirmação retirada após edição e apresentação final conferidos; sem API/escrita na base pela prévia. A primeira prévia importava um auxiliar Node com filesystem; foi substituído por JSON sintético gerado, sem alterar o produto para contornar o erro. Provas em outputs/irpc-pec-last-amount-20261113/. Não repetidas as provas aceites dos percursos não afectados.
Estado:implementado e verificado em QA; pacote agrupado, ainda sem instalação e dependente de autorização concreta. Não encerra L8 nem o portal. Restam períodos/regimes especiais, qualificação documental de casos fora deste recorte e os demais resíduos L1–L7/restanteL8/L9–L16 e módulos. Execução de pagamentos não é requisito do portal. A instalação deverá usar o executor Git, com backup/restauro fresco e reversão de release; preservar snapshots/históricos produzidos após a entrega, sem restaurar uma base antiga sobre edições posteriores.
## 13/11/2026 — lote PEC ordinário instalado em f059c226
Entrega expressamente autorizada e instalada pelo executor Git existente na main em https://teste-investidor.tempo.co.mz. Revisão servida f059c226d26ce0e9f5fec30ecfed44a65d61d6e3. Inclui referência ao último montante PEC documentado, reconciliação de PPC acima da base e apresentação do formulário/relatório. São hipóteses para viabilidade; não há execução de pagamentos, declarações, reembolso ou dedução criada pelo portal.
Backup/restauro fresco de193tabelas/413ficheiros e conferência pós-instalação de21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, histórico, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas.23fontes conferidas por hash; revisão pública e HTTPS verificados. Executor/configuração inalterados, zero migrações/importações; readiness demográfica vinculada à revisão com original arquivado. CI obrigatório executado normalmente; provas anteriores reutilizadas sem nova ronda manual. Recibo: outputs/irpc-pec-last-amount-20261113/homologation-installed.json. Pacote agrupado v2 selado preservado; a versão anterior do ZIP conserva-se como histórico de normalização de finais de linha, sem alteração do produto.
Este lote deixa de ser pendência de instalação. L8 continua parcial para períodos/regimes especiais e casos fora do recorte; os resíduos L1–L7/restanteL8/L9–L16 e restantes módulos mantêm o saldo próprio. Próximo lote compatível: projecção conjunta PPC/PEC no ecrã e relatório, separada dos comprovativos pagos e do fecho anual.
## 13/11/2026 — lote integrado de projecção mensal PPC/PEC, QA
Continuidade do saldo de integração e relatórios AUD-SIM-INT02/INT06, após f059c226 instalado. Antes deste delta, as estimativas PPC e a hipótese PEC eram apresentadas separadamente; faltava a vista mensal conjunta. O lote reúne a apresentação no ecrã L8, a mesma projecção no relatório do dossier e a separação entre estimativas e comprovativos documentados como pagos. Reutiliza os calculadores aceites, o livro, o rascunho, a recuperação e os exportadores existentes. Não acrescenta um motor fiscal, formulário de pagamento, API, tabela ou permissão.
A vista agrega cada componente uma única vez por mês, com fracções exactas. A opção PEC concentrada conserva o mês escolhido; a repartida conserva os três montantes explícitos. Uma componente desconhecida ou uma distribuição por reconciliar produz total por determinar e subtotais conhecidos identificados; não vira zero. Zero documentado é distinguido da falta de dados. São previsões PPC/PEC do recorte ordinário2026, não o saldo a executar ou todos os impostos do negócio. Valores pagos, deduções, saldo anual, fluxo financeiro e métricas do investimento não são acrescidos ou substituídos.
O relatório usa as opções guardadas na versão calculada, preservando as escolhas posteriores ainda por recalcular. Fontes e explicação aparecem no resumo da projecção; as linhas mensais conservam os montantes sem repetir extensamente as mesmas citações. A identificação de dossier/oportunidade e os exportadores existentes mantêm-se. Legados sem estas preparações não recebem uma projecção presumida.
QA:42testes frontend afectados, incluindo sete novos de agregação exacta, sobreposição, ausência, distribuição decimal, zero, recuperação, relatório e apresentação. Os sete testes do delta foram reconferidos após o ajuste de apresentação; o ensaio da vista L8 confirma os grupos separados, além da tabela. Prova nativa de POST/PUT/GET/recuperação em quatro cenários com42asserções e rollback na base isolada investmoz_demography_test:55441. Cenários:112000MZN totais com64000emMaio, distribuição por seis meses, PEC por completar sem total presumido, e zero explícito sem saída. Saldo anual sintético138MZN e histórico anterior preservados; relatório financeiro do mesmo dossier conferido. Tipos, lint focado sem reformatação dos ficheiros legados, Pint/sintaxe e build QA aprovados. Edge:componente real compilado e relatório, estados completo/parcial/zero e apresentação final sem explicações repetidas. Prévia sintética sem API/BD; provas aceites dos restantes percursos reutilizadas. Provas:outputs/irpc-cashflow-group-20261113/.
Estado:implementado e verificado em QA, ainda sem instalação. Homologação mantém f059c226. Pacote único compatível; instalação depende de autorização concreta, com backup/restauro fresco e reversão de release preservando snapshots e edições posteriores. Este lote encerra a integração visual e documental destas previsões mensais; não encerra L8 nem o portal. Períodos/regimes especiais, anualização PPC28(3), outros casos documentais e resíduos L1–L7/restanteL8/L9–L16 e módulos conservam o saldo próprio. Execução de pagamentos não é requisito do portal.
## 13/11/2026 — projecção fiscal agrupada instalada em 95b33275
Instalação expressamente autorizada, concluída pelo executor Git existente na homologação https://teste-investidor.tempo.co.mz. Revisão servida95b33275aae48a6209c67002788c177b3dd80fcb. Inclui vista mensal conjunta PPC/PEC, distinção completo/parcial/zero, separação de estimativas e comprovativos e o mesmo conteúdo no relatório do dossier, com fontes concentradas no resumo. Sem alteração dos totais do negócio, pagamentos, deduções ou histórico.
Backup/restauro fresco de193tabelas/414ficheiros,29fontes conferidas por hash e21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas. Zero migrações/importações; executor e configuração intactos. Readiness demográfica vinculada à revisão com original arquivado. HTTPS e revisão pública conferidos; CI obrigatório executado normalmente. Provas aceites reutilizadas sem nova ronda manual de percursos. Recibo:outputs/irpc-cashflow-group-20261113/homologation-installed.json. Pacote agrupado selado inalterado.
Este lote deixa de ser pendência de instalação. Não encerra o simulador ou o portal: períodos/regimes especiais, anualização28(3), casos documentais e resíduos L1–L7/restanteL8/L9–L16 e módulos mantêm o saldo próprio. Próximo lote compatível: coerência do período L6 nas estimativas, projecção e relatórios, preservando a preparação e o fecho anual independente.
## 14/11/2026 — lote de coerência do período L6 em PPC/PEC, QA
Continuidade do saldo AUD-SIM-INT02/INT06 após95b33275 instalado. Conferência delimitada do código: PPC/PEC ordinários e a projecção recebiam titular/exercício/tipo, sem confrontar as datas L6 conhecidas. Podiam apresentar meses civis num dossier com período curto ou não civil. O lote liga o contexto já existente aos dois estimadores, à vista mensal e ao relatório da mesma versão, sem duplicar o motor fiscal, criar API, tabela ou permissão.
As datas preparadas em L6 prevalecem para a conferência da preparação, mesmo quando ainda diferem do último cálculo L6; não são substituídas silenciosamente pelo resultado anterior. O período usado fica conservado no modelo da versão anual calculada. O relatório lê esse exemplar, preservando edições posteriores ainda por recalcular. Uma divergência face ao recorte civil2026 mantém as estimativas dependentes por reconciliar, sem calendário ou total presumidos. As entradas e as hipóteses de caixa ficam guardadas, sem alterar o cálculo anual independente.
A dispensa PEC do ano de início documentado mantém zero nos períodos válidos contidos em2026, incluindo o período curto de início; datas inválidas, outro exercício ou período não civil fora deste recorte não recebem dispensa presumida. Os avisos identificam só a componente afectada; se PPC e PEC estiverem ambos por reconciliar, o resumo reúne a causa comum. A chave de cálculo recebe um marcador apenas para os casos afectados pela nova conferência, sem invalidar globalmente os livros ordinários aceites. Livros e históricos antigos conservados. A preparação independente antiga, sem metadados L6 disponíveis, mantém o seu âmbito documental explícito.
QA iniciada em13/11 e fechada em14/11:61testes frontend afectados, incluindo oito novos de período/edição/recuperação/consumidores. Prova nativa POST/PUT/GET com42asserções, quatro cenários e rollback na base isolada investmoz_demography_test:55441. Civil completo mantém112000MZN de projecção; não civil e curto ficam por reconciliar; início preserva PECzero e PPC separado. Nos quatro cenários, o resultado anual independente coincide com o exemplar anterior sem as preparações e o histórico mantém-se. Originais L6 e datas são produzidos pelo percurso normal existente, sem fabricar um calendário oficial. Relatório financeiro do mesmo dossier conferido. Tipos, lint/formatação focados, Pint/sintaxe PHP e build QA aprovados. Edge:componentes reais compilados, quatro contextos, dispensa e aviso específico PPC, ecrã/relatório concordantes. Prévia sintética sem API ou base de dados. Provas aceites dos percursos não afectados reutilizadas; dependências financeiras e exportadores conferidos idênticos a95b33275. Provas:outputs/irpc-period-consistency-20261113/.
Estado:implementado e verificado em QA, sem instalação. Homologação mantém95b33275. Lote único para estimadores, projecção, recuperação e relatório. Encerra a lacuna de coerência do período conhecido; não implementa ainda os cálculos/calendários dos períodos ou regimes especiais nem a anualização PPC28(3). L8 e portal permanecem parciais; resíduos L1–L7/restanteL8/L9–L16 e módulos mantidos. Sem pagamentos, novas deduções, migrações, importações ou permissões. Instalação exige autorização concreta e o executor Git existente, com backup/restauro fresco e reversão de release preservando snapshots/edições posteriores.
## 14/11/2026 — coerência dos períodos fiscais instalada em 078f770d
Instalação expressamente autorizada, concluída pelo executor Git existente na homologação https://teste-investidor.tempo.co.mz. Revisão servida078f770dea3d901624e2d9d8566a2d0f30f5acfe. Inclui coerência entre o período preparado em L6, estimativas PPC/PEC, projecção e relatório; contextos fora do recorte civil ficam por reconciliar, com a dispensa PEC inicial preservada. Sem alteração dos totais do negócio, pagamentos, deduções ou histórico.
Backup/restauro fresco de193tabelas/415ficheiros,37fontes conferidas por hash e21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas. Zero migrações/importações; executor e configuração intactos. Readiness demográfica vinculada à revisão com original arquivado. HTTPS e revisão pública conferidos; CI obrigatório executado normalmente. Provas aceites reutilizadas sem nova ronda manual de percursos. Recibo:outputs/irpc-period-consistency-20261113/homologation-installed.json. Pacote agrupado selado inalterado.
Este lote deixa de ser pendência de instalação. Não encerra o simulador ou o portal: períodos/regimes especiais, anualização28(3), casos documentais e resíduos L1–L7/restanteL8/L9–L16 e módulos mantêm o saldo próprio. Próximo lote compatível: períodos anuais completos não civis documentados, preservando a preparação e o cálculo anual independente.
## 14/11/2026 — PPC/PEC em períodos anuais não civis documentados, QA
Continuidade executável de AUD-SIM-INT02/INT06 após078f770d instalado. Reutiliza L6, os calculadores PPC/PEC, o livro privado, o histórico, a projecção conjunta e os relatórios existentes. Acrescenta uma correspondência documental no mesmo livro anual: titular, exercício, datas e fundamento do período corrente, datas e fonte do período anterior e confirmação explícita dos montantes/fontes. As datas anteriores sugeridas não equivalem a comprovação. Nenhuma autorização fiscal é produzida pelo portal.
Recorte: residente geral/MZN, actividade comercial/industrial/agrícola, período corrente não civil com doze meses civis completos e termo2026, anterior igualmente completo, contíguo e com termo2025. Fonte aceite CIRPC7(2),Lei34/2007,BR52,original SHA256cdbef875ad7ccc1517b12faf5de0e893a70ed2ed81e33913cd6dc4a674474e6e; meses RIRPC27(1)(a)/29(1),BR16/I16-04-2008,original SHA256fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee. PPC nos5.º/7.º/9.ºmeses; PEC nos6.º/8.º/10.ºmeses. Fórmulas e aritmética racional reutilizadas, sem segundo motor ou novos parâmetros oficiais.
O PEC exige actividade documentada anterior ao início do período anterior neste recorte. Início não civil, volume anterior zero com histórico29(5), períodos curtos, meses incompletos, outros regimes e anualização PPC28(3) mantêm-se separados e pendentes. A opção de mês único só aceita um mês dentro do período documentado, como hipótese de tesouraria e sem certificar prazo legal.
Mudanças de datas/fontes não substituem conclusões ou valores. A correspondência financeira tem marcas independentes PPC/PEC: alterar PPC mantém PEC conhecido e vice-versa; o total fica parcial até à reconferência explícita. A distribuição PEC antiga é conservada sem deslocamento automático para meses não civis; exige vinculação expressa às novas datas, preservando montantes, opção e referência. O período usado e a declaração ficam congelados no resultado guardado, sem mudar o cálculo anual independente ou o histórico. Sem alteração global dos livros civis aceites.
QA:70testes frontend afectados distintos, incluindo nove novos de domínio/UI; sucesso, falta de fonte, divergência de titular/datas, período anterior curto/não contíguo, reconferência independente, preservação da distribuição, mês único e histórico. API nativa POST/PUT/GET:quatro cenários sintéticos,46asserções, rollback em investmoz_demography_test:55441, identidade PDO conferida. Cálculo→gravação→recuperação→relatório financeiro do mesmo dossier demonstrados. Tipos, lint focado, Pint e compilação aprovados. Edge com componentes reais compilados:112000MZN nos meses relativos correctos, concentração emJaneiro2026, total indeterminado sem fonte, PEC preservado quando PPC muda e193000MZN apenas após reconferência. Corrigido aviso contraditório de ano civil no caso documental incompleto; regressão acrescentada. Provas:outputs/irpc-noncivil-20261114/. Provas aceites não afectadas reutilizadas.
Estado:implementado e verificado em QA, ainda sem instalação. Homologação mantém078f770d. Este lote encerra apenas as estimativas PPC/PEC e a projecção deste recorte anual não civil documentado; não encerra L6/L8, a liquidação final não civil, o simulador ou o portal. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos preservados na matriz. Sem migrações, permissões, rotas, importações, pagamentos, declarações submetidas ou novas deduções. Instalação agrupada depende de autorização concreta, executor Git, backup/restauro fresco e reversão de release preservando edições posteriores.
File diff suppressed because one or more lines are too long
+18 -1
View File
@@ -54,9 +54,26 @@
.catalogue-filters-heading {
display: none;
}
#catalogue-filter-fields {
#catalogue-filter-fields,
.catalogue-service-fields {
padding: 0 1.15rem 1.15rem;
}
.catalogue-service-fields label {
min-width: 0;
font-size: 0.875rem;
font-weight: 600;
}
.catalogue-service-fields label input:not([type='checkbox']),
.catalogue-service-fields label select {
margin-top: 0.35rem;
font-weight: 400;
}
.catalogue-service-fields .catalogue-filter-submit > button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.catalogue-filters input:not([type='checkbox']),
.catalogue-filters select {
min-height: 44px;
+6
View File
@@ -32,6 +32,8 @@ export function AppSidebar() {
contentManagementAccess,
profileAccess,
scopedAdministration,
dashboardNavigation,
administrationNavigation,
} = usePage<{
auth: Auth;
existingBusinessEnabled?: boolean;
@@ -41,6 +43,8 @@ export function AppSidebar() {
opportunityManagement?: OpportunityManagementAccess | null;
contentManagementHref?: string | null;
scopedAdministration?: { label: string; href: string }[];
dashboardNavigation?: { label: string; href: string }[];
administrationNavigation?: { label: string; href: string }[];
contentManagementAccess?: {
opportunities?: { index: string; create?: string | null };
};
@@ -56,6 +60,8 @@ export function AppSidebar() {
contentManagementHref,
contentManagementAccess,
scopedAdministration,
dashboardNavigation,
administrationNavigation,
legacyContent: currentPage.props.legacyContentEnabled as
| boolean
| undefined,
@@ -22,6 +22,7 @@ export type QuantitativeMapContext = {
source_reference?: string | null;
source_date?: string | null;
knowledge: string;
measure_kind?: 'unknown' | 'observed' | 'planned' | 'projected';
missing_metadata: string[];
}[];
zones_consulted?: number;
@@ -47,6 +48,17 @@ const metadataLabels: Record<string, string> = {
source_date: 'data da fonte',
};
const text = (value?: string | null) => value?.trim() || 'Não indicado';
const measureKinds: Record<string, string> = {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
};
const measureKind = (row: QuantitativeMapContext['records'][number]) =>
row.measure_kind &&
['observed', 'planned', 'projected'].includes(row.measure_kind)
? row.measure_kind
: 'unknown';
const completeDate = (value?: string | null) =>
Boolean(
value &&
@@ -55,6 +67,286 @@ const completeDate = (value?: string | null) =>
new Date(value).toISOString().slice(0, 10) === value,
);
type MapObservation = QuantitativeMapContext['records'][number];
// Calendar metadata only: inclusive source dates, never a rate or annualisation.
function periodDays(row: MapObservation): number | null {
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
row.period_start! > row.period_end!
)
return null;
return (
(Date.parse(row.period_end!) - Date.parse(row.period_start!)) /
86_400_000 +
1
);
}
const observationKey = (row: MapObservation) =>
JSON.stringify([row.zone_id, row.id, row.zone_version]);
export function compareMapObservations(
first: MapObservation,
second: MapObservation,
) {
const issues: string[] = [];
if (observationKey(first) === observationKey(second))
issues.push('Escolha duas observações distintas.');
if (first.zone_id !== second.zone_id)
issues.push(
'Territórios distintos: os valores não representam uma série do mesmo local.',
);
for (const [label, row] of [
['Primeira', first],
['Segunda', second],
] as const) {
if (measureKind(row) === 'unknown')
issues.push(
`${label} observação: natureza do valor por confirmar na fonte.`,
);
if (row.knowledge !== 'documented')
issues.push(
`${label} observação: informação desconhecida ou em divergência.`,
);
if (
row.value === null ||
!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(row.value)
)
issues.push(
`${label} observação: valor quantitativo por confirmar.`,
);
const comparisonMetadata: Record<string, string> = {
...metadataLabels,
description: 'descrição/natureza da grandeza',
};
const missing = Object.keys(comparisonMetadata).filter(
(field) => !row[field as keyof MapObservation]?.toString().trim(),
);
if (missing.length)
issues.push(
`${label} observação sem ${missing.map((field) => comparisonMetadata[field]).join(', ')}.`,
);
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
(row.period_start ?? '') > (row.period_end ?? '')
)
issues.push(`${label} observação: período incompleto ou inválido.`);
if (!completeDate(row.source_date))
issues.push(
`${label} observação: data da fonte incompleta ou inválida.`,
);
}
const firstDays = periodDays(first);
const secondDays = periodDays(second);
if (firstDays !== null && secondDays !== null) {
if (firstDays !== secondDays)
issues.push(
'Duração dos períodos diferente; não trate os valores como bases temporais equivalentes.',
);
if (
(first.period_start !== second.period_start ||
first.period_end !== second.period_end) &&
first.period_start! <= second.period_end! &&
second.period_start! <= first.period_end!
)
issues.push(
'Os períodos sobrepõem-se; as observações podem abranger os mesmos dias.',
);
}
if (measureKind(first) !== measureKind(second))
issues.push(
'Natureza do valor diferente: observado, planeado e projectado não são equivalentes.',
);
for (const [field, label] of Object.entries({
indicator: 'Indicador',
unit: 'Unidade',
scope: 'Âmbito',
population: 'População/base',
methodology: 'Metodologia',
})) {
if (
(first[field as keyof MapObservation] ?? '').toString().trim() !==
(second[field as keyof MapObservation] ?? '').toString().trim()
)
issues.push(`${label} diferente entre as observações.`);
}
return {
state: issues.length ? 'review_required' : 'metadata_aligned',
issues,
};
}
export function MapObservationComparison({
first,
second,
}: {
first?: MapObservation;
second?: MapObservation;
}) {
if (!first || !second)
return (
<p role="status">
Seleccione duas observações disponíveis nos filtros actuais.
</p>
);
const assessment = compareMapObservations(first, second);
const fields: [string, (row: MapObservation) => string][] = [
['Território', (row) => row.zone_name],
['Observação', (row) => text(row.label)],
['Descrição e natureza da grandeza', (row) => text(row.description)],
['Natureza do valor', (row) => measureKinds[measureKind(row)]],
['Indicador', (row) => labels[row.indicator] ?? row.indicator],
[
'Valor original',
(row) => (row.value === null ? 'Por confirmar' : row.value),
],
['Unidade', (row) => text(row.unit)],
[
'Período',
(row) => `${text(row.period_start)} — ${text(row.period_end)}`,
],
[
'Duração do período (dias, limites incluídos)',
(row) => {
const days = periodDays(row);
return days === null ? 'Por confirmar' : String(days);
},
],
['Âmbito', (row) => text(row.scope)],
['População/base', (row) => text(row.population)],
['Metodologia', (row) => text(row.methodology)],
[
'Situação da informação',
(row) =>
row.knowledge === 'documented'
? 'Documentada'
: row.knowledge === 'disputed'
? 'Em divergência'
: 'Por confirmar',
],
['Fonte', (row) => text(row.source_reference)],
['Data da fonte', (row) => text(row.source_date)],
['Versão da ficha territorial', (row) => String(row.zone_version)],
];
return (
<div className="space-y-3">
<p role="status">
{assessment.state === 'metadata_aligned'
? 'Metadados alinhados; confirme nas fontes se os valores podem ser comparados.'
: 'Há diferenças ou lacunas a conferir antes de comparar os valores.'}
</p>
{!!assessment.issues.length && (
<ul className="list-disc pl-5">
{assessment.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<p>
Períodos distintos ou sobrepostos exigem conferência da duração
e da base na fonte. Esta apresentação não calcula evolução,
soma, conversão, pontuação ou estimativa regional. A natureza
declarada deve ser conferida com a descrição e a fonte.
</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="sr-only">
Conferência de duas observações quantitativas
</caption>
<thead>
<tr>
<th className="p-2">Campo</th>
<th className="p-2">Primeira observação</th>
<th className="p-2">Segunda observação</th>
</tr>
</thead>
<tbody>
{fields.map(([label, value]) => (
<tr key={label} className="border-t">
<th className="p-2" scope="row">
{label}
</th>
<td className="p-2 break-words">
{value(first)}
</td>
<td className="p-2 break-words">
{value(second)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function MapObservationComparisonPicker({ rows }: { rows: MapObservation[] }) {
const id = useId();
const [firstKey, setFirst] = useState('');
const [secondKey, setSecond] = useState('');
const first = rows.find((row) => observationKey(row) === firstKey);
const second = rows.find((row) => observationKey(row) === secondKey);
return (
<details className="space-y-3 rounded border p-3">
<summary>Comparar duas observações</summary>
<p>
Confronte os dados e as fontes antes de os usar na decisão.
Selecções fora dos filtros deixam de ser comparadas; uma versão
nova exige nova selecção.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{(['Primeira', 'Segunda'] as const).map((label, index) => (
<label
className="grid gap-1"
key={label}
htmlFor={`${id}-${index}`}
>
{label} observação
<select
className="w-full min-w-0 rounded border p-2"
id={`${id}-${index}`}
value={
index === 0
? first
? firstKey
: ''
: second
? secondKey
: ''
}
onChange={(event) =>
(index === 0 ? setFirst : setSecond)(
event.target.value,
)
}
>
<option value="">Seleccionar observação</option>
{rows.map((row) => (
<option
key={observationKey(row)}
value={observationKey(row)}
>
{row.zone_name} —{' '}
{row.label ||
labels[row.indicator] ||
row.indicator}{' '}
— {row.period_start || '?'} /{' '}
{row.period_end || '?'} — v
{row.zone_version}
</option>
))}
</select>
</label>
))}
</div>
<MapObservationComparison first={first} second={second} />
</details>
);
}
export function filterMapIndicators(
records: QuantitativeMapContext['records'],
indicator: string,
@@ -114,9 +406,9 @@ export default function InvestmentMapIndicators({
contexto territorial, independentes das evidências das
oportunidades. Emprego não mede trabalhadores disponíveis;
investimento, produção e exportações não constituem um índice de
potencial ou garantia de retorno. A natureza prevista ou
efectiva de cada valor depende da descrição da fonte, não é
determinada automaticamente.
potencial ou garantia de retorno. A natureza do valor é
declarada segundo a fonte; sem essa indicação permanece por
confirmar.
</p>
{!context ? (
<p role="status">A carregar indicadores quantitativos…</p>
@@ -242,6 +534,9 @@ export default function InvestmentMapIndicators({
observações. Refine a localização.
</p>
)}
{rows.length > 1 && (
<MapObservationComparisonPicker rows={rows} />
)}
{rows.length === 0 ? (
<p>
{filtered
@@ -308,6 +603,14 @@ export default function InvestmentMapIndicators({
'Por determinar'}{' '}
· {text(row.unit)}
</p>
<p>
Natureza:{' '}
{
measureKinds[
measureKind(row)
]
}
</p>
<p>
{row.knowledge ===
'documented'
@@ -441,7 +441,11 @@ export default function InvestmentTerritory({
},
);
setVersion(data.version);
setMessage('Localização associada e guardada.');
setMessage(
data.pending_publication
? 'Localização guardada na edição privada para revisão; a publicação anterior está preservada.'
: 'Localização associada e guardada.',
);
} catch (e) {
setMessage(
e instanceof Error ? e.message : 'Erro ao guardar.',
@@ -60,7 +60,8 @@ const labels: Record<string, string> = {
geometry_id: 'Geometria já associada ao parque',
zone_link_id: 'Relação económica já associada ao parque',
indicator: 'Indicador',
value: 'Valor observado',
measure_kind: 'Natureza do valor segundo a fonte',
value: 'Valor indicado na fonte',
unit: 'Unidade de medida',
period_start: 'Início do período',
period_end: 'Fim do período',
@@ -86,6 +87,7 @@ const extras: Record<Group, string[]> = {
],
observations: [
'indicator',
'measure_kind',
'value',
'unit',
'period_start',
@@ -105,6 +107,12 @@ const extras: Record<Group, string[]> = {
],
};
const choices: Record<string, Record<string, string>> = {
measure_kind: {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
},
knowledge: {
unknown: 'Não informado',
documented: 'Documentado pela fonte',
@@ -248,7 +256,11 @@ export default function ParkIntegralEditor({
})
}
>
<option value="">Não informado / sem associação</option>
<option value="">
{key === 'measure_kind'
? 'Por confirmar na fonte'
: 'Não informado / sem associação'}
</option>
{Object.entries(options).map(([k, v]) => (
<option key={k} value={k}>
{v}
@@ -541,7 +553,9 @@ export function ParkIntegralPublic({ value }: { value?: ParkIntegral }) {
)
: item[k] == null ||
item[k] === ''
? 'Não informado'
? k === 'measure_kind'
? 'Por confirmar'
: 'Não informado'
: k === 'expansion'
? item[k]
? 'Sim, segundo a fonte'
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
createInvestorDossierReport,
createInvestorStageReport,
investorDossierReportIndex,
type InvestorDossierReportInput,
} from '@/lib/investor-dossier-report';
@@ -13,9 +14,33 @@ export function InvestorDossierReports({
dossier,
}: {
dossier: InvestorDossierReportInput;
}) {
return (
<InvestorDossierReportsContent
key={`${dossier.case.id}:${dossier.case.version}:${dossier.context_opportunity ?? ''}`}
dossier={dossier}
/>
);
}
function InvestorDossierReportsContent({
dossier,
}: {
dossier: InvestorDossierReportInput;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [stage, setStage] = useState('');
const [download, setDownload] = useState<{
url: string;
name: string;
} | null>(null);
useEffect(
() => () => {
if (download) URL.revokeObjectURL(download.url);
},
[download],
);
const requiresChoice =
dossier.opportunity_options.length > 1 && !dossier.context_opportunity;
const entries = requiresChoice ? [] : investorDossierReportIndex(dossier);
@@ -23,7 +48,9 @@ export function InvestorDossierReports({
setBusy(true);
setError('');
try {
const model = createInvestorDossierReport(dossier);
const model = stage
? createInvestorStageReport(dossier, stage)
: createInvestorDossierReport(dossier);
const blob =
format === 'word'
? await generateProjectDossierDocx(model, [], true)
@@ -38,11 +65,8 @@ export function InvestorDossierReports({
{ type: 'text/html;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}.${format === 'word' ? 'docx' : 'html'}`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
const name = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}${stage ? `-${stage}` : ''}.${format === 'word' ? 'docx' : 'html'}`;
setDownload({ url, name });
} catch (cause) {
setError(
cause instanceof Error
@@ -72,22 +96,61 @@ export function InvestorDossierReports({
</p>
) : (
<>
<label className="block">
Relatório a consultar e descarregar
<select
className="mt-1 block w-full rounded border p-2"
value={stage}
disabled={busy}
onChange={(event) => {
setStage(event.target.value);
setDownload(null);
}}
>
<option value="">Dossier completo</option>
{dossier.steps.map((step) => (
<option key={step.code} value={step.code}>
{step.code} — {step.title}
</option>
))}
</select>
</label>
{stage && (
<StageReportPreview dossier={dossier} stage={stage} />
)}
<div className="flex flex-wrap gap-3">
<button
className="rounded border px-3 py-2"
disabled={busy}
onClick={() => exportReport('word')}
>
Descarregar relatório consolidado (Word)
Preparar{' '}
{stage
? `relatório ${stage}`
: 'relatório consolidado'}{' '}
(Word)
</button>
<button
className="rounded border px-3 py-2"
disabled={busy}
onClick={() => exportReport('html')}
>
Descarregar versão para impressão/PDF
Preparar versão para impressão/PDF
</button>
</div>
{busy && <p role="status">A preparar o ficheiro…</p>}
{download && (
<p role="status">
Ficheiro pronto.{' '}
<a
className="font-medium underline"
href={download.url}
download={download.name}
>
Descarregar {download.name}
</a>
</p>
)}
<p className="text-sm">
A versão HTML pode ser aberta e impressa ou guardada
como PDF. As descargas são privadas; não enviam
@@ -163,3 +226,46 @@ export function InvestorDossierReports({
</section>
);
}
function StageReportPreview({
dossier,
stage,
}: {
dossier: InvestorDossierReportInput;
stage: string;
}) {
const report = createInvestorStageReport(dossier, stage);
return (
<article
className="space-y-3 rounded border p-3"
aria-label={`Relatório da etapa ${stage}`}
>
<h3 className="font-semibold">{report.title}</h3>
<p className="text-sm">{report.version}</p>
{report.sections.map((section) => (
<section key={section.id} className="space-y-2">
<h4 className="font-medium">{section.title}</h4>
{section.paragraphs.map((text) => (
<p key={text}>{text}</p>
))}
<dl className="space-y-2">
{section.rows.map((row, index) => (
<div key={index} className="break-words">
<dt className="font-medium">{row.field}</dt>
<dd>{row.value}</dd>
<dd className="text-sm">
{row.classification} · {row.origin}
</dd>
</div>
))}
</dl>
</section>
))}
{report.limitations.map((text) => (
<p className="text-sm" key={text}>
{text}
</p>
))}
</article>
);
}
@@ -53,6 +53,8 @@ export function PortalGroupedMenu() {
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
scopedAdministration?: { label: string; href: string }[];
dashboardNavigation?: { label: string; href: string }[];
administrationNavigation?: { label: string; href: string }[];
contentManagementHref?: string | null;
contentManagementAccess?: {
opportunities?: { index: string; create?: string | null };
@@ -79,6 +81,8 @@ export function PortalGroupedMenu() {
contentManagementHref: props.contentManagementHref,
contentManagementAccess: props.contentManagementAccess,
scopedAdministration: props.scopedAdministration,
dashboardNavigation: props.dashboardNavigation,
administrationNavigation: props.administrationNavigation,
});
const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url);
@@ -93,12 +97,17 @@ export function PortalGroupedMenu() {
children.map((item) =>
item.children ? (
mobile ? (
<Fragment key={item.id}>
<DropdownMenuLabel className="mt-2 border-t px-3 pt-3 text-xs text-slate-500">
<details
key={item.id}
className="rounded-lg border-b border-slate-100"
>
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-slate-800">
{t(item.label)}
</DropdownMenuLabel>
{items(item.children)}
</Fragment>
</summary>
<div className="border-l border-slate-200 pl-2">
{items(item.children)}
</div>
</details>
) : (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
@@ -138,6 +147,7 @@ export function PortalGroupedMenu() {
'realization',
'support',
'work',
'administration',
].includes(group.id),
);
const institutionalCurrent = url.split('?')[0] === '/contacto';
@@ -203,6 +213,7 @@ export function PortalGroupedMenu() {
'discovery',
'realization',
'support',
'administration',
].includes(group.id),
)
.map((group) => (
+3 -3
View File
@@ -259,7 +259,7 @@ export default function PortalHero({
aria-describedby="portal-opportunity-search-help"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Ex.: agronegócio, Nampula, legislação..."
placeholder="Corredores, produtos e locais, licenciamento…"
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
/>
<button
@@ -274,8 +274,8 @@ export default function PortalHero({
id="portal-opportunity-search-help"
className="mt-2 text-left text-sm text-white/90"
>
Combine palavras-chave, sector e localização. Ex.:
plantio de algodão ou agronegócio Nampula.
Ex.: quantos corredores existem; grafite Cabo Delgado;
como licenciar uma empresa.
</p>
</form>
@@ -22,8 +22,11 @@ export function PortalSidebarNavigation({
}) {
const active = currentNavigationItem(groups, url);
const ordered = [
...groups.filter((group) => group.id === 'administration'),
...groups.filter((group) => group.id === 'work'),
...groups.filter((group) => group.id !== 'work'),
...groups.filter(
(group) => group.id !== 'work' && group.id !== 'administration',
),
];
const links = (items: PortalNavItem[], depth = 0): ReactNode =>
items.map((item) =>
@@ -1,3 +1,4 @@
import type { AdvanceFiscalPeriod } from '@/lib/irpc-ppc-obligation';
import {SimulationIrpcPecObligation} from './simulation-irpc-pec-obligation';
import { SimulationIrpcPpcObligation } from './simulation-irpc-ppc-obligation';
import { SimulationIrpcInvestment } from './simulation-irpc-investment';
@@ -8,6 +9,12 @@ import { SimulationIrpcInternational } from './simulation-irpc-international';
import { SimulationIrpcPec } from './simulation-irpc-pec';
import { useState } from 'react';
import {
advancePeriodBasis,
advanceCalendar,
newAdvancePeriodDeclaration,
advanceCashflowAmount,
annualAdvanceCashflow,
annualAdvancePeriod,
annualBook,
newAnnualBook,
saveAnnualBook,
@@ -77,6 +84,7 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
);
const patch = (p: Partial<AnnualBook>) => save({ ...b, ...p }),
a = annualAssessment(draft),
advancePeriod = annualAdvancePeriod(draft,b),
options = annualWithholdingOptions(draft);
const field = (
key: 'subjectNuit' | 'year' | 'reference' | 'assessmentReference',
@@ -341,10 +349,15 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
})}
</fieldset>
<fieldset className="space-y-2 rounded border p-3">
<legend>PPC efectivamente pagos</legend>
<legend>Estimativas PPC e PEC para a viabilidade</legend>
<p>Simulação fiscal para avaliar a viabilidade do negócio e as necessidades de tesouraria. Os valores e meses previstos são estimativas; o portal não executa pagamentos nem submete declarações fiscais.</p>
<SimulationIrpcPecObligation book={b} onChange={pecObligation=>patch({pecObligation})}/>
<SimulationIrpcPpcObligation book={b} onChange={ppcObligation=>patch({ppcObligation})}/>
<SimulationIrpcAdvancePeriodDeclaration draft={draft} book={b} onChange={declaration=>patch({advancePeriodDeclaration:declaration})}/>
<SimulationIrpcPecObligation book={b} period={advancePeriod} onChange={pecObligation=>patch({pecObligation})}/>
<SimulationIrpcPpcObligation book={b} period={advancePeriod} onChange={ppcObligation=>patch({ppcObligation})}/>
<SimulationIrpcAdvanceCashflow book={b} period={advancePeriod}/>
</fieldset>
<fieldset className="space-y-2 rounded border p-3">
<legend>PPC documentados como pagos</legend>
{group('ppc', 'PPC efectuados')}
<p>
Regista comprovativos de pagamentos por conta; não
@@ -613,3 +626,34 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
);
}
}
export function SimulationIrpcAdvanceCashflow({book,period}: {book: AnnualBook;period?: AdvanceFiscalPeriod}) {
if (!book.ppcObligation && !book.pecObligation) return null;
const m = annualAdvanceCashflow(book,period);
const show = advanceCashflowAmount;
return <section className="space-y-3 rounded border p-3" aria-label="Projecção conjunta PPC e PEC">
<h5 className="font-semibold">Saídas previstas PPC/PEC por mês</h5>
{period && <p>Período preparado em L6: {period.from || 'por confirmar'} — {period.to || 'por confirmar'}.</p>}
<p>Reúne as estimativas PPC e a hipótese de tesouraria PEC. Os comprovativos pagos e o fecho anual permanecem separados; esta vista não acrescenta outra saída ao fluxo financeiro do negócio.</p>
<p role="status">{m.total ? 'Total da projecção: '+show(m.total) : 'Projecção parcial — total por determinar.'}</p>
{!!m.pending.length && <ul className="list-disc pl-5">{m.pending.map(p=><li key={p}>{p}</li>)}</ul>}
{!!m.rows.length && <div className="overflow-x-auto"><table className="w-full text-left text-sm">
<caption className="sr-only">Projecção mensal das estimativas PPC e PEC</caption>
<thead><tr><th className="p-2">Mês</th><th className="p-2">PPC</th><th className="p-2">PEC</th><th className="p-2">{m.total ? 'Total previsto' : 'Subtotal conhecido'}</th></tr></thead>
<tbody>{m.rows.map(r=><tr key={r.month} className="border-t"><th scope="row" className="p-2">{r.month}</th><td className="p-2">{m.ppcKnown ? show(r.ppc) : 'Por determinar'}</td><td className="p-2">{m.pecKnown ? show(r.pec) : 'Por determinar'}</td><td className="p-2">{show(r.knownSubtotal)}</td></tr>)}</tbody>
</table></div>}
{!m.rows.length && m.total && <p>Sem saída PPC/PEC estimada neste recorte.</p>}
<p>Meses e montantes dependem das fontes e opções preparadas acima. A opção PEC concentrada conserva o mês escolhido; esta apresentação não certifica o prazo legal.</p>
</section>;
}
export function SimulationIrpcAdvancePeriodDeclaration({draft,book,onChange}:{draft:Record<string,unknown>;book:AnnualBook;onChange:(value:NonNullable<AnnualBook['advancePeriodDeclaration']>)=>void}){
const period=annualAdvancePeriod(draft,book),calendar=advanceCalendar(period,book.year,book.subjectNuit),d=book.advancePeriodDeclaration;
if(!d&&!calendar.candidate)return null;
if(!d)return <section className="rounded border p-3"><h5>Período anual não civil</h5><p>Confronte o fundamento do período L6 e os doze meses do período anterior. As datas sugeridas não substituem o suporte documental.</p><button type="button" className="rounded border p-2" onClick={()=>onChange(newAdvancePeriodDeclaration(book.subjectNuit,book.year,period))}>Preparar correspondência dos períodos</button></section>;
const patch=(values:Partial<typeof d>)=>onChange({...d,...values,confirmed:''});
const labels={subjectNuit:'NUIT dos períodos',year:'Exercício de termo',currentFrom:'Início do período L6 declarado',currentTo:'Termo do período L6 declarado',currentReference:'Fundamento documentado do período anual não civil',previousFrom:'Início do período anterior',previousTo:'Termo do período anterior',previousReference:'Fonte e correspondência do período anterior'};
return <section className="space-y-2 rounded border p-3" aria-label="Correspondência dos períodos anuais"><h5>Correspondência dos períodos anuais</h5><p role="status">{calendar.mode==='annual_non_civil'?'Correspondência documental conferida;previsões nos meses do período L6.':calendar.mode==='civil'?'L6 usa o ano civil;referência anterior conservada sem aplicação.':calendar.pending.join(' ')}</p><p>PPC: imposto anterior {book.ppcObligation?.previousTax||'por confirmar'} MZN;retenções {book.ppcObligation?.previousWithholdings||'por confirmar'} MZN. PEC: volume anterior {book.pecObligation?.previousTurnover||'por confirmar'} MZN;PPC anteriores {book.pecObligation?.previousPpcPaid||'por confirmar'} MZN.</p>{d.confirmed==='yes'&&(d.ppcBasisKey!==advancePeriodBasis(book,'ppc')||d.pecBasisKey!==advancePeriodBasis(book,'pec'))&&<button type="button" className="rounded border p-2" onClick={()=>onChange({...d,ppcBasisKey:advancePeriodBasis(book,'ppc'),pecBasisKey:advancePeriodBasis(book,'pec')})}>Reconferi os montantes e as fontes neste período</button>}<details open={d.confirmed!=='yes'||calendar.pending.length>0}><summary>Conferir ou alterar períodos</summary><label className="block">Aplicação<select aria-label="Aplicação do período não civil" value={d.mode} onChange={e=>patch({mode:e.target.value as typeof d.mode})}><option value="annual_non_civil">Período anual não civil</option><option value="">Conservar sem aplicação</option></select></label>{(Object.keys(labels) as Array<keyof typeof labels>).map(key=><label className="block" key={key}>{labels[key]}<input className="mt-1 block w-full rounded border px-3 py-2" aria-label={labels[key]} value={d[key]} onChange={e=>patch({[key]:e.target.value})}/></label>)}<p>No PPC, indique que o período anterior não é um ano civil completo. Os doze meses e a continuidade são conferidos pelas datas e fontes aqui indicadas.</p><label className="block"><input type="checkbox" checked={d.confirmed==='yes'} onChange={e=>onChange({...d,confirmed:e.target.checked?'yes':'',ppcBasisKey:advancePeriodBasis(book,'ppc'),pecBasisKey:advancePeriodBasis(book,'pec')})}/>Conferi no suporte os dois períodos, o fundamento do enquadramento e a correspondência dos montantes e fontes PPC/PEC</label></details><p>Guardar conserva estas escolhas. Uma alteração nas datas L6 exige reconferência; o portal não autoriza uma mudança do período fiscal.</p></section>;
}
@@ -1,5 +1,6 @@
import {SimulationIrpcPecCorrection} from './simulation-irpc-pec-correction';
import {SimulationIrpcPecPlan} from './simulation-irpc-pec-plan';
import type { AdvanceFiscalPeriod } from '@/lib/irpc-ppc-obligation';
import { SimulationIrpcPecCorrection } from './simulation-irpc-pec-correction';
import { SimulationIrpcPecPlan } from './simulation-irpc-pec-plan';
import {
newPecObligation,
calculatePecObligation,
@@ -9,9 +10,11 @@ import { fractionPresentation } from '@/lib/fiscal-rational';
import type { AnnualBook } from '@/lib/irpc-annual-settlement';
export function SimulationIrpcPecObligation({
book,
period,
onChange,
}: {
book: AnnualBook;
period?: AdvanceFiscalPeriod;
onChange: (p: PecObligation) => void;
}) {
const p = book.pecObligation;
@@ -38,16 +41,21 @@ export function SimulationIrpcPecObligation({
book.subjectNuit,
book.year,
book.assessment,
period,
),
patch = (changes: Partial<PecObligation>) =>
onChange({ ...p, ...changes });
const field = (
key: Exclude<keyof PecObligation, 'version' | 'scope' | 'plan' | 'correction'>,
key: Exclude<
keyof PecObligation,
'version' | 'scope' | 'plan' | 'correction' | 'lastAmount'
>,
label: string,
) => (
<label className="block">
{label}
<input
className="mt-1 block w-full rounded border px-3 py-2"
aria-label={label}
value={p[key]}
onChange={(e) => patch({ [key]: e.target.value })}
@@ -63,6 +71,7 @@ export function SimulationIrpcPecObligation({
<label className="block">
Enquadramento PEC
<select
className="mt-1 block w-full rounded border px-3 py-2"
aria-label="Enquadramento PEC"
value={p.scope}
onChange={(e) =>
@@ -74,7 +83,7 @@ export function SimulationIrpcPecObligation({
<option value="">Por confirmar</option>
<option value="ordinary_resident">
Residente geral — comercial,industrial ou agrícola,ano
civil
civil ou anual não civil documentado
</option>
<option value="other">
Outro enquadramento — separado
@@ -104,7 +113,131 @@ export function SimulationIrpcPecObligation({
'turnoverReference',
'Fonte do volume de negócios — PEC',
)}
<SimulationIrpcPecCorrection value={p.correction} original={p.previousTurnover} subjectNuit={p.subjectNuit} onChange={correction=>patch({correction})}/>
<SimulationIrpcPecCorrection
value={p.correction}
original={p.previousTurnover}
subjectNuit={p.subjectNuit}
onChange={(correction) => patch({ correction })}
/>
{(m.requiresLastAmount || p.lastAmount) && (
<details className="rounded border p-3">
<summary>
Sem volume anterior — referência PEC dos
anos anteriores
</summary>
<p>
Indique o último montante PEC já
determinado, com suporte do mesmo
titular, ano civil e MZN. Não introduza
volume de negócios ou somatório de
pagamentos. A hipótese conserva o valor
documental sem nova percentagem ou
segunda dedução de PPC.
</p>
{!p.lastAmount ? (
<button
type="button"
onClick={() =>
patch({
lastAmount: {
version: 1,
subjectNuit:
p.subjectNuit,
year: '',
amount: '',
reference: '',
confirmed: '',
},
})
}
>
Acrescentar referência PEC anterior
</button>
) : (
<>
{(
[
'subjectNuit',
'year',
'amount',
'reference',
] as const
).map((key) => {
const names = {
subjectNuit:
'NUIT do montante PEC anterior',
year: 'Exercício do último montante PEC',
amount: 'Último montante PEC já determinado — MZN',
reference:
'Fonte e fundamento do último montante PEC',
};
return (
<label
className="block"
key={key}
>
{names[key]}
<input
className="mt-1 block w-full rounded border px-3 py-2"
aria-label={
names[key]
}
value={
p.lastAmount![
key
]
}
onChange={(e) =>
patch({
lastAmount:
{
...p.lastAmount!,
[key]: e
.target
.value,
confirmed:
'',
},
})
}
/>
</label>
);
})}
<label className="block">
<input
type="checkbox"
checked={
p.lastAmount
.confirmed === 'yes'
}
onChange={(e) =>
patch({
lastAmount: {
...p.lastAmount!,
confirmed: e
.target
.checked
? 'yes'
: '',
},
})
}
/>
Conferi no suporte que é o
último montante PEC aplicável a
esta hipótese
</label>
<p>
Alterar a referência exige nova
conferência. Guardar conserva o
original e o histórico; não
certifica a situação fiscal.
</p>
</>
)}
</details>
)}
{field(
'previousPpcPaid',
'PPC efectivamente pagos no ano anterior — MZN',
@@ -124,18 +257,32 @@ export function SimulationIrpcPecObligation({
) : (
<>
<p>
Total estimado:{' '}
{fractionPresentation(m.total!).decimal} MZN.
Total estimado: {fractionPresentation(m.total!).decimal}{' '}
MZN.
</p>
<ul>
{m.months.map((month) => (
<li key={month}>{month}</li>
))}
</ul>
</>
)}
<SimulationIrpcPecPlan plan={p.plan} total={m.total} notApplicable={m.notApplicable} year={book.year} onChange={plan=>patch({plan})}/>
{m.ppcExcess && (
<p role="status">
Os PPC anteriores cobrem a base limitada desta hipótese.
Estimativa PEC: 0 MZN;diferença documental:{' '}
{fractionPresentation(m.ppcExcess).decimal} MZN. Esta
diferença não cria reembolso,crédito ou dedução anual.
</p>
)}
<SimulationIrpcPecPlan
plan={p.plan}
total={m.total}
notApplicable={m.notApplicable}
year={book.year}
period={period}
onChange={(plan) => patch({ plan })}
/>
<p>
Guardar o rascunho conserva esta preparação. Calcular o fecho
anual inclui-a no relatório. Não regista pagamentos,cria
@@ -1,7 +1,7 @@
import type { AdvanceFiscalPeriod } from '@/lib/irpc-ppc-obligation';
import {
newPecPlan,
calculatePecPlan,
pecPlanSource,
type PecPlan,
} from '@/lib/irpc-pec-plan';
import {
@@ -13,12 +13,14 @@ export function SimulationIrpcPecPlan({
total,
notApplicable,
year,
period,
onChange,
}: {
plan?: PecPlan;
total: FiscalFraction | null;
notApplicable: boolean;
year: string;
period?: AdvanceFiscalPeriod;
onChange: (p: PecPlan) => void;
}) {
if (!plan)
@@ -33,13 +35,13 @@ export function SimulationIrpcPecPlan({
disabled={
!total || notApplicable || BigInt(total.numerator) <= 0n
}
onClick={() => onChange(newPecPlan())}
onClick={() => onChange(newPecPlan(period))}
>
Simular tesouraria PEC
</button>
</div>
);
const m = calculatePecPlan(plan, total, notApplicable, year),
const m = calculatePecPlan(plan, total, notApplicable, year, period),
patch = (p: Partial<PecPlan>) => onChange({ ...plan, ...p });
const field = (
key: 'reference' | 'june' | 'august' | 'october',
@@ -77,6 +79,20 @@ export function SimulationIrpcPecPlan({
. Prazo legal não validado.
</p>
)}
{m.needsPeriodConfirmation && !m.calendarPending && (
<button
type="button"
className="rounded border p-2"
onClick={() =>
patch({
periodFrom: m.contextFrom,
periodTo: m.contextTo,
})
}
>
Conferi a distribuição para {m.contextFrom} — {m.contextTo}
</button>
)}
<details open={m.pending.length > 0}>
<summary>Alterar projecção PEC</summary>
<label className="block">
@@ -100,9 +116,30 @@ export function SimulationIrpcPecPlan({
{field('reference', 'Fundamento do projecção PEC')}
{plan.mode === 'three' && (
<>
{field('june', 'PEC Junho — MZN')}
{field('august', 'PEC Agosto — MZN')}
{field('october', 'PEC Outubro — MZN')}
{field(
'june',
m.calendarPending
? 'PEC primeiro montante — mês por confirmar'
: m.nonCivil
? 'PEC 6.º mês (' + m.months[0] + ') — MZN'
: 'PEC Junho — MZN',
)}
{field(
'august',
m.calendarPending
? 'PEC segundo montante — mês por confirmar'
: m.nonCivil
? 'PEC 8.º mês (' + m.months[1] + ') — MZN'
: 'PEC Agosto — MZN',
)}
{field(
'october',
m.calendarPending
? 'PEC terceiro montante — mês por confirmar'
: m.nonCivil
? 'PEC 10.º mês (' + m.months[2] + ') — MZN'
: 'PEC Outubro — MZN',
)}
</>
)}
{plan.mode === 'single' && (
@@ -128,7 +165,7 @@ export function SimulationIrpcPecPlan({
</p>
<details>
<summary>Fonte do projecção PEC</summary>
<p>{pecPlanSource}</p>
<p>{m.source}</p>
</details>
</section>
);
@@ -1,3 +1,4 @@
import type { AdvanceFiscalPeriod } from '@/lib/irpc-ppc-obligation';
import {
newPpcObligation,
calculatePpcObligation,
@@ -8,9 +9,11 @@ import { fractionPresentation } from '@/lib/fiscal-rational';
import type { AnnualBook } from '@/lib/irpc-annual-settlement';
export function SimulationIrpcPpcObligation({
book,
period,
onChange,
}: {
book: AnnualBook;
period?: AdvanceFiscalPeriod;
onChange: (value: PpcObligation) => void;
}) {
const p = book.ppcObligation;
@@ -19,7 +22,7 @@ export function SimulationIrpcPpcObligation({
<section className="rounded border p-3">
<h5>Estimativa PPC</h5>
<p>
Preparar a obrigação e os meses previstos não regista
Preparar a estimativa e os meses previstos não regista
pagamentos nem cria deduções na liquidação.
</p>
<button
@@ -37,6 +40,7 @@ export function SimulationIrpcPpcObligation({
book.subjectNuit,
book.year,
book.assessment,
period,
);
const patch = (changes: Partial<PpcObligation>) =>
onChange({ ...p, ...changes });
@@ -85,7 +89,7 @@ export function SimulationIrpcPpcObligation({
<option value="">Por confirmar</option>
<option value="ordinary_resident">
Residente geral — actividade comercial, industrial ou
agrícola, ano civil
agrícola, período documentado
</option>
<option value="other">
Outro enquadramento — cálculo separado
+10
View File
@@ -0,0 +1,10 @@
export const states: Record<string, string> = {
draft: 'Rascunho',
submitted: 'Submetido',
in_review: 'Em revisão',
approved: 'Aprovado',
published: 'Publicado',
returned: 'Devolvido',
rejected: 'Rejeitado',
archived: 'Arquivado',
};
@@ -552,3 +552,63 @@ export function createInvestorDossierReport(
],
};
}
/** A preparation summary, never a substitute for an external act or its original. */
export function createInvestorStageReport(
input: InvestorDossierReportInput,
stageCode: string,
generatedAt = new Date().toISOString(),
): DossierReportModel {
const step = input.steps.find((item) => item.code === stageCode);
if (!step) throw new Error('Etapa não encontrada neste dossier.');
const report = createInvestorDossierReport(input, generatedAt);
const entries = investorDossierReportIndex(input).filter((entry) =>
entry.stage.split(', ').includes(stageCode),
);
const origins = new Set(entries.map((entry) => entry.href));
const specificSections = new Set([
...(stageCode === 'S02' ? ['market'] : []),
...(stageCode === 'S03' ? ['finance'] : []),
...(['S06', 'S07'].includes(stageCode) ? ['sources'] : []),
]);
return {
...report,
title: `${input.case.title} — ${stageCode}: ${step.title}`,
version: `${report.version} · etapa v${step.version ?? 'não registada'}`,
limitations: [
...report.limitations,
'Resumo dos registos disponíveis nesta etapa; não cria documentos, decisões ou resultados em falta.',
],
sections: report.sections
.filter(
(section) =>
['project', 'summary', 'execution', 'resources'].includes(
section.id,
) || specificSections.has(section.id),
)
.map((section) => {
if (section.id === 'summary')
return {
...section,
rows: section.rows.filter((item) =>
origins.has(item.origin),
),
};
if (section.id === 'execution')
return {
...section,
rows: section.rows.filter((item) =>
item.field.startsWith(`${stageCode} —`),
),
};
if (section.id === 'resources')
return {
...section,
rows: section.rows.filter((item) =>
origins.has(item.origin),
),
};
return section;
}),
};
}
+215
View File
@@ -0,0 +1,215 @@
export type AdvancePeriodDeclaration = {
version: 1;
mode: '' | 'annual_non_civil';
subjectNuit: string;
year: string;
currentFrom: string;
currentTo: string;
currentReference: string;
previousFrom: string;
previousTo: string;
previousReference: string;
ppcBasisKey: string;
pecBasisKey: string;
confirmed: '' | 'yes';
};
export type AdvanceFiscalPeriod = {
from?: string | null;
to?: string | null;
subjectNuit?: string;
ppcBasisKey?: string;
pecBasisKey?: string;
declaration?: AdvancePeriodDeclaration;
} | null;
export const advanceCalendarVersion = 'IRPC-annual-noncivil-calendar/1';
const iso = (s?: string | null): s is string =>
Boolean(
s &&
/^20\d{2}-\d{2}-\d{2}$/.test(s) &&
Number.isFinite(Date.parse(s)) &&
new Date(s).toISOString().slice(0, 10) === s,
);
const index = (s: string) =>
Number(s.slice(0, 4)) * 12 + Number(s.slice(5, 7)) - 1;
const month = (from: string, offset: number) =>
new Date(
Date.UTC(
Number(from.slice(0, 4)),
Number(from.slice(5, 7)) - 1 + offset,
1,
),
)
.toISOString()
.slice(0, 7);
const end = (s: string) =>
new Date(Date.UTC(Number(s.slice(0, 4)), Number(s.slice(5, 7)), 0))
.toISOString()
.slice(0, 10);
const previousDay = (s: string) =>
new Date(Date.parse(s) - 86400000).toISOString().slice(0, 10);
const annual = (from?: string | null, to?: string | null) =>
iso(from) &&
iso(to) &&
from.endsWith('-01') &&
to === end(to) &&
index(to) - index(from) === 11;
export function newAdvancePeriodDeclaration(
subjectNuit: string,
year: string,
period: AdvanceFiscalPeriod,
): AdvancePeriodDeclaration {
const from = period?.from ?? '',
to = period?.to ?? '';
return {
version: 1,
mode: 'annual_non_civil',
subjectNuit,
year,
currentFrom: from,
currentTo: to,
currentReference: '',
previousFrom: iso(from) ? month(from, -12) + '-01' : '',
previousTo: iso(from) ? previousDay(from) : '',
previousReference: '',
ppcBasisKey: '',
pecBasisKey: '',
confirmed: '',
};
}
export function validateAdvancePeriodDeclaration(d: AdvancePeriodDeclaration) {
if (
!d ||
d.version !== 1 ||
!['', 'annual_non_civil'].includes(d.mode) ||
!['', 'yes'].includes(d.confirmed) ||
[
'subjectNuit',
'year',
'currentFrom',
'currentTo',
'currentReference',
'previousFrom',
'previousTo',
'previousReference',
'ppcBasisKey',
'pecBasisKey',
].some(
(k) =>
typeof (d as unknown as Record<string, unknown>)[k] !==
'string',
)
)
throw Error(
'Correspondência de períodos não recuperável;original preservado.',
);
}
export function advanceCalendar(
period: AdvanceFiscalPeriod | undefined,
year: string,
subjectNuit?: string,
) {
const from = period?.from ?? '',
to = period?.to ?? '';
if (!/^20\d{2}$/.test(year))
return {
version: advanceCalendarVersion,
mode: 'unavailable' as const,
candidate: false,
pending: ['exercício das estimativas por confirmar'],
from,
to,
ppcMonths: [],
pecMonths: [],
previousFrom: '',
previousTo: '',
source: '',
};
const civil =
!period || (from === year + '-01-01' && to === year + '-12-31');
const candidate = annual(from, to) && to.slice(0, 4) === year && !civil;
let mode: 'civil' | 'annual_non_civil' | 'unavailable' = civil
? 'civil'
: 'unavailable';
let pending: string[] = [];
const d = period?.declaration;
if (!civil) {
if (d?.mode === 'annual_non_civil') {
validateAdvancePeriodDeclaration(d);
const issues: string[] = [];
if (!candidate)
issues.push(
'o período L6 deve conter doze meses civis completos com termo no exercício indicado',
);
if (
d.subjectNuit !== (subjectNuit ?? period?.subjectNuit) ||
d.year !== year ||
d.currentFrom !== from ||
d.currentTo !== to
)
issues.push(
'titular,exercício ou datas declaradas divergem de L6',
);
if (
!annual(d.previousFrom, d.previousTo) ||
!iso(from) ||
d.previousTo !== previousDay(from) ||
d.previousTo.slice(0, 4) !== String(Number(year) - 1)
)
issues.push(
'o período anterior deve ser anual completo e imediatamente anterior ao período L6',
);
if (
!d.currentReference.trim() ||
!d.previousReference.trim() ||
d.confirmed !== 'yes'
)
issues.push(
'faltam fontes e confirmação da correspondência dos dois períodos',
);
if (issues.length)
pending = [
'período anual não civil: ' +
issues.join('; ') +
'. Preparação conservada.',
];
else mode = 'annual_non_civil';
} else
pending = [
'o período L6 ' +
(from || 'por confirmar') +
' — ' +
(to || 'por confirmar') +
' não corresponde ao ano civil ' +
year +
'. Reconciliar o enquadramento antes de usar as estimativas ordinárias; a preparação fica conservada.',
];
}
const start = mode === 'civil' ? year + '-01-01' : from;
return {
version: advanceCalendarVersion,
mode,
candidate,
pending,
from: mode === 'civil' ? year + '-01-01' : from,
to: mode === 'civil' ? year + '-12-31' : to,
ppcMonths:
mode === 'unavailable' ? [] : [4, 6, 8].map((i) => month(start, i)),
pecMonths:
mode === 'unavailable' ? [] : [5, 7, 9].map((i) => month(start, i)),
previousFrom:
mode === 'annual_non_civil'
? d!.previousFrom
: String(Number(year) - 1) + '-01-01',
previousTo:
mode === 'annual_non_civil'
? d!.previousTo
: String(Number(year) - 1) + '-12-31',
source:
mode === 'annual_non_civil'
? 'CIRPC7(2),Lei34/2007,BR52,original SHA256cdbef875ad7ccc1517b12faf5de0e893a70ed2ed81e33913cd6dc4a674474e6e; RIRPC27(1)(a)/29(1),BR16/I16-04-2008,original SHA256fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee. Períodos anuais completos documentados;5.º/7.º/9.º mês PPC e6.º/8.º/10.º mês PEC. ' +
d!.currentReference +
'; ' +
d!.previousReference
: '',
};
}
+94 -4
View File
@@ -1,7 +1,10 @@
import {validateAdvancePeriodDeclaration,type AdvancePeriodDeclaration} from './irpc-advance-calendar.ts';
export {advanceCalendar,newAdvancePeriodDeclaration} from './irpc-advance-calendar.ts';
import { calculatePecPlan } from './irpc-pec-plan.ts';
import {pecCorrectionVersion} from './irpc-pec-correction.ts';
import {pecPlanVersion} from './irpc-pec-plan.ts';
import {validatePecObligation,pecObligationReport,pecObligationVersion,type PecObligation} from './irpc-pec-obligation.ts';
import { validatePpcObligation, ppcObligationReport, ppcObligationVersion, type PpcObligation } from './irpc-ppc-obligation.ts';
import {validatePecObligation,pecObligationReport,pecObligationVersion,pecPpcExcessVersion,calculatePecObligation,type PecObligation} from './irpc-pec-obligation.ts';
import { ordinaryAdvancePeriodIssues, type AdvanceFiscalPeriod, calculatePpcObligation, validatePpcObligation, ppcObligationReport, ppcObligationVersion, type PpcObligation } from './irpc-ppc-obligation.ts';
import {
newInvestmentCredit,
validateInvestmentCredit,
@@ -126,6 +129,7 @@ export type AnnualBook = {
creditCarry?: CreditCarry;
economicCredits?: EconomicCredits;
investmentCredit?: InvestmentCredit;
advancePeriodDeclaration?: AdvancePeriodDeclaration;
ppcObligation?: PpcObligation;
pecObligation?: PecObligation;
result?: AnnualRecord;
@@ -297,6 +301,7 @@ function books(d: Record<string, unknown>): Record<string, AnnualBook> {
return m;
}
export function validateAnnual(b: AnnualBook) {
if(b?.advancePeriodDeclaration!==undefined)validateAdvancePeriodDeclaration(b.advancePeriodDeclaration);
if(b?.pecObligation!==undefined)validatePecObligation(b.pecObligation);
if (b?.ppcObligation !== undefined) validatePpcObligation(b.ppcObligation);
if (b?.investmentCredit !== undefined)
@@ -421,6 +426,17 @@ export function annualAssessment(d: Record<string, unknown>) {
: zero(),
};
}
export function advancePeriodBasis(b:AnnualBook,kind:'ppc'|'pec'):string {
let value:unknown=b.ppcObligation??null;
if(kind==='pec'){if(b.pecObligation){const {plan:_,...basis}=b.pecObligation;value=basis;}else value=null;}
return closureFingerprint([b.subjectNuit,b.year,b.assessment,value]);
}
export function annualAdvancePeriod(d: Record<string, unknown>,b?: AnnualBook): AdvanceFiscalPeriod {
const prepared = normalBook(d).periodsFx;
const dates=prepared ? {from: prepared.from, to: prepared.to} : annualAssessment(d).period;
return b?.advancePeriodDeclaration ? {...dates,subjectNuit:b.subjectNuit,ppcBasisKey:advancePeriodBasis(b,'ppc'),pecBasisKey:advancePeriodBasis(b,'pec'),declaration:structuredClone(b.advancePeriodDeclaration)} : dates;
}
export function reuseAnnualAssessment(
d: Record<string, unknown>,
b: AnnualBook,
@@ -568,13 +584,18 @@ function dependencies(d: Record<string, unknown>, includeInvestment = false) {
}
function key(d: Record<string, unknown>, b: AnnualBook) {
const { result: _, history: __, ...input } = b;
const period = annualAdvancePeriod(d,b);
const periodGuard = ordinaryAdvancePeriodIssues(period, b.year).length > 0 &&
(b.ppcObligation || b.pecObligation && !calculatePecObligation(b.pecObligation,b.subjectNuit,b.year,b.assessment,period).notApplicable);
return closureFingerprint([
annualSettlementVersion,
...(periodGuard ? ['IRPC-advance-period-consistency/1'] : []),
acquisitionScope(d),
input,
dependencies(d, !!b.investmentCredit),
...(b.ppcObligation ? [ppcObligationVersion] : []),
...(b.pecObligation ? [pecObligationVersion] : []),
...(b.pecObligation && calculatePecObligation(b.pecObligation,b.subjectNuit,b.year,b.assessment).ppcExcess ? [pecPpcExcessVersion] : []),
...(b.pecObligation?.plan ? [pecPlanVersion] : []),
...(b.pecObligation?.correction ? [pecCorrectionVersion] : []),
...(b.investmentCredit && b.creditCarry?.lots.length
@@ -1592,6 +1613,7 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
return {
version: annualSettlementVersion,
input: structuredClone(input),
...(b.ppcObligation || b.pecObligation ? {advancePeriod: annualAdvancePeriod(d,b)} : {}),
subjectNuit: b.subjectNuit,
year: b.year,
assessment: assessment?.record
@@ -1724,8 +1746,9 @@ export function annualReportRows(
indeterminate: 'Indeterminado',
}[m.status];
return [
...(m.input.pecObligation ? pecObligationReport(m.input.pecObligation,m.subjectNuit,m.year,m.input.assessment) : []),
...(m.input.ppcObligation ? ppcObligationReport(m.input.ppcObligation,m.subjectNuit,m.year,m.input.assessment) : []),
...(m.input.pecObligation ? pecObligationReport(m.input.pecObligation,m.subjectNuit,m.year,m.input.assessment,m.advancePeriod ?? m.assessment?.period) : []),
...(m.input.ppcObligation ? ppcObligationReport(m.input.ppcObligation,m.subjectNuit,m.year,m.input.assessment,m.advancePeriod ?? m.assessment?.period) : []),
...annualAdvanceCashflowReport(m.input,m.advancePeriod ?? m.assessment?.period),
[
'IRPC — L8',
'Fecho anual — âmbito e origem',
@@ -2407,3 +2430,70 @@ export function annualHtml(d: Record<string, unknown>) {
'</html>'
);
}
/** Derived cash view only. Reuses the existing estimates; never changes the annual ledger. */
export function annualAdvanceCashflow(b: Pick<AnnualBook, 'subjectNuit' | 'year' | 'assessment' | 'ppcObligation' | 'pecObligation'>, period?: AdvanceFiscalPeriod) {
const pending: string[] = [];
const sources: string[] = [];
const amounts: Array<{month: string; kind: 'ppc' | 'pec'; amount: FiscalFraction}> = [];
let ppcKnown = false, pecKnown = false;
if (!b.ppcObligation) pending.push('Prepare a estimativa PPC para completar a projecção conjunta.');
else {
const p = calculatePpcObligation(b.ppcObligation, b.subjectNuit, b.year, b.assessment, period);
sources.push(b.ppcObligation.assessmentReference, b.ppcObligation.withholdingReference, p.source);
pending.push(...p.pending);
ppcKnown = p.pending.length === 0 && p.total !== null;
if (ppcKnown) amounts.push(...p.schedule.map(r => ({...r, kind: 'ppc' as const})));
}
if (!b.pecObligation) pending.push('Prepare a estimativa PEC para completar a projecção conjunta.');
else {
const p = calculatePecObligation(b.pecObligation, b.subjectNuit, b.year, b.assessment, period);
sources.push(b.pecObligation.turnoverReference, b.pecObligation.ppcReference, p.source);
pending.push(...p.pending);
if (!p.pending.length && p.total) {
if (BigInt(p.total.numerator) === 0n) pecKnown = true;
else if (!b.pecObligation.plan) pending.push('Distribua a hipótese de tesouraria PEC para completar os meses da projecção conjunta.');
else {
const allocation = calculatePecPlan(b.pecObligation.plan, p.total, p.notApplicable, b.year, period);
sources.push(b.pecObligation.plan.reference, allocation.source);
pending.push(...allocation.pending);
pecKnown = allocation.allocationReconciled;
if (pecKnown) amounts.push(...allocation.rows.map(r => ({...r, kind: 'pec' as const})));
}
}
}
const grouped = new Map<string, {month: string; ppc: FiscalFraction; pec: FiscalFraction}>();
for (const row of amounts) {
if (BigInt(row.amount.numerator) === 0n) continue;
const current = grouped.get(row.month) ?? {month: row.month, ppc: fraction(0n), pec: fraction(0n)};
current[row.kind] = addFractions([current[row.kind], row.amount]);
grouped.set(row.month, current);
}
const rows = [...grouped.values()].sort((a,b) => a.month.localeCompare(b.month)).map(r => ({...r, knownSubtotal: addFractions([r.ppc,r.pec])}));
const knownSubtotal = addFractions(rows.map(r => r.knownSubtotal));
let issues = [...new Set(pending)];
const periodIssues = ['PPC','PEC'].map(kind => ordinaryAdvancePeriodIssues(period,b.year,kind as 'PPC' | 'PEC')[0]);
if (periodIssues.every(issue => issue && issues.includes(issue))) {
issues = issues.filter(issue => !periodIssues.includes(issue));
issues.push(...ordinaryAdvancePeriodIssues(period,b.year));
}
return {version:'IRPC-advance-cashflow/1', ppcKnown, pecKnown, pending:issues, rows,
total: ppcKnown && pecKnown ? knownSubtotal : null, knownSubtotal, sources:[...new Set(sources.filter(s => s.trim()))]};
}
export function advanceCashflowAmount(value: FiscalFraction): string {
return fractionPresentation(value).decimal+' MZN';
}
export function annualAdvanceCashflowReport(b: Pick<AnnualBook, 'subjectNuit' | 'year' | 'assessment' | 'ppcObligation' | 'pecObligation'>, period?: AdvanceFiscalPeriod): Array<[string,string,string,string]> {
if (!b.ppcObligation && !b.pecObligation) return [];
const m = annualAdvanceCashflow(b, period);
const show = advanceCashflowAmount;
const source = m.sources.join('; ');
return [
['IRPC — L8', 'Projecção mensal conjunta PPC/PEC', (m.total ? 'Total da projecção: '+show(m.total)+'.' : 'Projecção parcial; total por determinar. '+m.pending.join(' '))+' Estimativas de tesouraria; comprovativos pagos, deduções e fecho anual separados. Esta vista não acrescenta outra saída ao fluxo financeiro.', source],
...m.rows.map(r => ['IRPC — L8', 'PPC/PEC — '+r.month,
'PPC: '+(m.ppcKnown ? show(r.ppc) : 'por determinar')+'; PEC: '+(m.pecKnown ? show(r.pec) : 'por determinar')+'; '+(m.total ? 'total previsto: ' : 'subtotal conhecido: ')+show(r.knownSubtotal)+'.', ''] as [string,string,string,string]),
];
}
+284 -35
View File
@@ -1,5 +1,19 @@
import {validatePecCorrection,correctedPecTurnover,pecCorrectionSource,type PecCorrection} from './irpc-pec-correction.ts';
import {validatePecPlan,pecPlanReport,type PecPlan} from './irpc-pec-plan.ts';
import { advanceCalendar } from './irpc-advance-calendar.ts';
import {
ordinaryAdvancePeriodIssues,
type AdvanceFiscalPeriod,
} from './irpc-ppc-obligation.ts';
import {
validatePecCorrection,
correctedPecTurnover,
pecCorrectionSource,
type PecCorrection,
} from './irpc-pec-correction.ts';
import {
validatePecPlan,
pecPlanReport,
type PecPlan,
} from './irpc-pec-plan.ts';
import {
decimalFraction,
fraction,
@@ -8,9 +22,21 @@ import {
type FiscalFraction,
} from './fiscal-rational.ts';
export const pecObligationVersion = 'IRPC-PEC-obligation/1';
export const pecPpcExcessVersion = 'IRPC-PEC-PPC-excess/1';
export const pecObligationSource =
'RIRPC,Decreto9/2008,art29(1–5),BR16/I16-04-2008,original SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee.0,5% do volume de negócios anterior,limites30.000/100.000MZN,menos PPC efectivamente pagos no ano anterior.Junho/Agosto/Outubro;opção de pagamento único prevista,sem data ou repartição inventada. Ano de início excluído29(4);sem volume anterior exige último montante29(5). Recorte2026,residente geral comercial/industrial/agrícola,ano civil,MZN. Regimes/períodos especiais,rectificação29(3),base de anos anteriores29(5),guias/juros separados.';
export type PecLastAmount = {
version: 1;
subjectNuit: string;
year: string;
amount: string;
reference: string;
confirmed: '' | 'yes';
};
export const pecLastAmountSource =
'RIRPC,Decreto9/2008,art29(5),BR16/I16-04-2008,p122/PDF24,original SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee. Hipótese documentada: último montante PEC já determinado,para titular/ano civil/MZN correspondentes;sem recalcular 0,5%,limites ou deduzir PPC pela segunda vez. Não demonstra por si a qualificação fiscal do caso.';
export type PecObligation = {
lastAmount?: PecLastAmount;
version: 1;
plan?: PecPlan;
correction?: PecCorrection;
@@ -42,8 +68,24 @@ export const newPecObligation = (
ppcReference: '',
});
export function validatePecObligation(p: PecObligation) {
if(p?.correction!==undefined)validatePecCorrection(p.correction);
if(p?.plan!==undefined)validatePecPlan(p.plan);
if (p?.lastAmount !== undefined) {
const v = p.lastAmount;
if (
!v ||
v.version !== 1 ||
!['', 'yes'].includes(v.confirmed) ||
['subjectNuit', 'year', 'amount', 'reference'].some(
(k) =>
typeof (v as unknown as Record<string, unknown>)[k] !==
'string',
)
)
throw Error(
'Referência PEC anterior não recuperável;original preservado.',
);
}
if (p?.correction !== undefined) validatePecCorrection(p.correction);
if (p?.plan !== undefined) validatePecPlan(p.plan);
if (
!p ||
p.version !== 1 ||
@@ -65,14 +107,23 @@ export function calculatePecObligation(
subjectNuit: string,
year: string,
assessment: string,
period?: AdvanceFiscalPeriod,
) {
validatePecObligation(p);
const pending: string[] = [];
const turnover=correctedPecTurnover(p.correction,p.previousTurnover,p.subjectNuit,p.previousYear);
const calendar = advanceCalendar(period, year, subjectNuit);
const turnover = correctedPecTurnover(
p.correction,
p.previousTurnover,
p.subjectNuit,
p.previousYear,
);
let percentage: FiscalFraction | null = null,
limited: FiscalFraction | null = null,
total: FiscalFraction | null = null,
notApplicable = false;
ppcExcess: FiscalFraction | null = null,
notApplicable = false,
lastAmountApplied = false;
if (
p.scope !== 'ordinary_resident' ||
assessment !== 'normal' ||
@@ -94,10 +145,58 @@ export function calculatePecObligation(
'PEC: falta ano de início efectivo da actividade e suporte;constituição da empresa não substitui início.',
);
if (!pending.length && p.activityStartYear === '2026') {
notApplicable = true;
total = fraction(0n);
const validStartDate = (value?: string | null) =>
Boolean(
value &&
/^2026-\d{2}-\d{2}$/.test(value) &&
Number.isFinite(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value,
);
if (
period &&
!(
validStartDate(period.from) &&
validStartDate(period.to) &&
period.from! <= period.to!
)
)
pending.push(
...ordinaryAdvancePeriodIssues(
period,
year,
'PEC',
subjectNuit,
),
);
if (calendar.mode === 'annual_non_civil')
pending.push(
'PEC: início de actividade em período não civil exige correspondência própria;fora deste recorte de continuidade.',
);
if (!pending.length) {
notApplicable = true;
total = fraction(0n);
}
} else if (!pending.length) {
pending.push(
...ordinaryAdvancePeriodIssues(period, year, 'PEC', subjectNuit),
);
if (
calendar.mode === 'annual_non_civil' &&
(!period?.pecBasisKey ||
period.declaration?.pecBasisKey !== period.pecBasisKey)
)
pending.push(
'PEC: montantes ou fontes alterados;reconfira a sua correspondência com o período anterior,sem substituir os valores.',
);
pending.push(...turnover.pending);
if (
calendar.mode === 'annual_non_civil' &&
Number(p.activityStartYear) >=
Number(calendar.previousFrom.slice(0, 4))
)
pending.push(
'PEC: este recorte exige actividade já iniciada antes do período anterior documentado;início ou transição têm correspondência própria.',
);
if (
p.previousYear !== '2025' ||
!p.turnoverReference.trim() ||
@@ -117,11 +216,33 @@ export function calculatePecObligation(
if (!pending.length) {
const v = decimalFraction(turnover.value),
ppc = decimalFraction(p.previousPpcPaid);
if (BigInt(v.numerator) === 0n)
if (
BigInt(v.numerator) === 0n &&
calendar.mode === 'annual_non_civil'
) {
pending.push(
'PEC29(5):sem volume de negócios em2025,recuperar último montante dos anos anteriores;zero não determina automaticamente o mínimo.',
'PEC29(5): último montante num período não civil exige correspondência da sua origem;fora deste recorte de continuidade.',
);
else {
} else if (BigInt(v.numerator) === 0n) {
const last = p.lastAmount;
if (
!last ||
last.confirmed !== 'yes' ||
!last.reference.trim() ||
last.subjectNuit !== subjectNuit ||
!/^\d{4}$/.test(last.year) ||
Number(last.year) < Number(p.activityStartYear) ||
Number(last.year) >= Number(year) ||
!/^\d{1,18}(\.\d{1,2})?$/.test(last.amount)
) {
pending.push(
'PEC29(5):sem volume de negócios em2025,recuperar último montante PEC já determinado dos anos anteriores,com titular,exercício,fonte e confirmação de que é o último aplicável;zero não determina automaticamente o mínimo.',
);
} else {
total = decimalFraction(last.amount);
lastAmountApplied = true;
}
} else {
percentage = fraction(
BigInt(v.numerator),
BigInt(v.denominator) * 200n,
@@ -135,25 +256,67 @@ export function calculatePecObligation(
? fraction(100000n)
: percentage;
const residual = subtractFractions(limited, ppc);
if (BigInt(residual.numerator) < 0n)
pending.push(
'PEC:PPC anteriores excedem o montante limitado;caso separado para reconciliação,não gera estimativa negativa ou reembolso.',
);
else total = residual;
if (BigInt(residual.numerator) < 0n) {
total = fraction(0n);
ppcExcess = subtractFractions(ppc, limited);
} else total = residual;
}
}
}
let source = p.correction
? pecObligationSource.replace('rectificação29(3),', '') +
' ' +
pecCorrectionSource
: pecObligationSource;
if (p.lastAmount)
source =
source.replace('base de anos anteriores29(5),', '') +
' ' +
pecLastAmountSource;
if (calendar.mode === 'annual_non_civil')
source =
source
.replace(
'ano civil,MZN',
'período anual não civil completo,MZN',
)
.replace('Junho/Agosto/Outubro', '6.º/8.º/10.º mês do período')
.replace(
'Regimes/períodos especiais',
'Períodos curtos e outros regimes',
) +
' ' +
calendar.source;
if (ppcExcess)
source +=
' Reconciliação da estimativa29(2):PPC documentados cobrem a base limitada;diferença conservada sem gerar direito a reembolso ou crédito.';
return {
version: pecObligationVersion,
source: p.correction ? pecObligationSource.replace('rectificação29(3),','')+' '+pecCorrectionSource : pecObligationSource,
source,
nonCivil: calendar.mode === 'annual_non_civil',
lastAmountSource: p.lastAmount ? pecLastAmountSource : null,
pending,
percentage,
limited,
total,
notApplicable,
turnoverCorrection: p.correction ? {original:p.correction.originalTurnover,revised:p.correction.revisedTurnover,applied:!notApplicable&&turnover.applied,pending:turnover.pending,reference:p.correction.reference,source:pecCorrectionSource}:null,
months:
total && !notApplicable ? ['2026-06', '2026-08', '2026-10'] : [],
lastAmountApplied,
ppcExcess,
requiresLastAmount:
!notApplicable &&
turnover.pending.length === 0 &&
/^0+(\.0+)?$/.test(turnover.value),
turnoverCorrection: p.correction
? {
original: p.correction.originalTurnover,
revised: p.correction.revisedTurnover,
applied: !notApplicable && turnover.applied,
pending: turnover.pending,
reference: p.correction.reference,
source: pecCorrectionSource,
}
: null,
months: total && !notApplicable ? calendar.pecMonths : [],
instalmentAmountsDetermined: false,
paymentExecuted: false,
annualDeductionCreated: false,
@@ -164,15 +327,92 @@ export function pecObligationReport(
subjectNuit: string,
year: string,
assessment: string,
period?: AdvanceFiscalPeriod,
): Array<[string, string, string, string]> {
const m = calculatePecObligation(p, subjectNuit, year, assessment),
const m = calculatePecObligation(p, subjectNuit, year, assessment, period),
show = (v: FiscalFraction | null) =>
v ? fractionPresentation(v).decimal + ' MZN' : 'Indeterminado';
const originalEstimate=p.correction ? calculatePecObligation({...p,correction:undefined},subjectNuit,year,assessment).total:null;
const correctionEffect=originalEstimate&&m.total ? fractionPresentation(subtractFractions(m.total,originalEstimate)).decimal+' MZN':'por determinar';
const originalEstimate = p.correction
? calculatePecObligation(
{ ...p, correction: undefined },
subjectNuit,
year,
assessment,
period,
).total
: null;
const correctionEffect =
originalEstimate && m.total
? fractionPresentation(subtractFractions(m.total, originalEstimate))
.decimal + ' MZN'
: 'por determinar';
return [
...(p.correction ? [['IRPC — L8','PEC — rectificação do volume','Original: '+p.correction.originalTurnover+' MZN;corrigido: '+p.correction.revisedTurnover+' MZN;'+(m.turnoverCorrection?.applied?'aplicada à estimativa.':'conservada sem aplicação à estimativa.')+' Estimativa original: '+show(originalEstimate)+';efeito na estimativa: '+correctionEffect+'. '+turnoverCorrectionNotice(m.turnoverCorrection),p.correction.reference+'; '+pecCorrectionSource] as [string,string,string,string]]:[]),
...(p.plan ? pecPlanReport(p.plan,m.total,m.notApplicable,year) : []),
...(m.ppcExcess
? [
[
'IRPC — L8',
'PEC — PPC acima da base limitada',
'PPC documentados: ' +
p.previousPpcPaid +
' MZN;base limitada: ' +
show(m.limited) +
';diferença: ' +
show(m.ppcExcess) +
';estimativa PEC: 0 MZN. A diferença é apenas reconciliação desta hipótese; não cria reembolso,crédito,transporte ou dedução anual.',
p.ppcReference +
'; ' +
p.turnoverReference +
'; RIRPC29(2); ' +
pecPpcExcessVersion,
] as [string, string, string, string],
]
: []),
...(p.lastAmount
? [
[
'IRPC — L8',
'PEC — último montante documentado',
'Exercício ' +
(p.lastAmount.year || 'por confirmar') +
';montante PEC já determinado: ' +
(p.lastAmount.amount || 'por confirmar') +
' MZN;' +
(m.lastAmountApplied
? 'usado como hipótese documentada29(5),sem novo cálculo percentual ou segunda dedução de PPC.'
: 'conservado sem aplicação à estimativa.') +
' Não regista pagamento nem dedução anual.',
(p.lastAmount.reference || 'Fonte por indicar') +
'; ' +
pecLastAmountSource,
] as [string, string, string, string],
]
: []),
...(p.correction
? [
[
'IRPC — L8',
'PEC — rectificação do volume',
'Original: ' +
p.correction.originalTurnover +
' MZN;corrigido: ' +
p.correction.revisedTurnover +
' MZN;' +
(m.turnoverCorrection?.applied
? 'aplicada à estimativa.'
: 'conservada sem aplicação à estimativa.') +
' Estimativa original: ' +
show(originalEstimate) +
';efeito na estimativa: ' +
correctionEffect +
'. ' +
turnoverCorrectionNotice(m.turnoverCorrection),
p.correction.reference + '; ' + pecCorrectionSource,
] as [string, string, string, string],
]
: []),
...(p.plan
? pecPlanReport(p.plan, m.total, m.notApplicable, year, period)
: []),
[
'IRPC — L8',
'PEC — estimativa fiscal',
@@ -180,15 +420,22 @@ export function pecObligationReport(
? m.pending.join(' ')
: m.notApplicable
? 'Não aplicável no ano de início da actividade29(4);total0 MZN.'
: '0,5%: ' +
show(m.percentage) +
';limitado antes de PPC: ' +
show(m.limited) +
';total estimado: ' +
show(m.total) +
';meses: ' +
m.months.join(', ') +
(p.plan ? '. Plano explícito apresentado separadamente;prazo legal não validado.' : '. Montantes por prestação e opção de pagamento único não determinados neste recorte.') + ' Preparação não é pagamento nem dedução anual.',
: m.lastAmountApplied
? 'Hipótese documentada29(5): ' +
show(m.total) +
';último montante PEC já determinado,sem segunda dedução de PPC. Confirmar enquadramento com o suporte indicado.'
: '0,5%: ' +
show(m.percentage) +
';limitado antes de PPC: ' +
show(m.limited) +
';total estimado: ' +
show(m.total) +
';meses: ' +
m.months.join(', ') +
(p.plan
? '. Plano explícito apresentado separadamente;prazo legal não validado.'
: '. Montantes por prestação e opção de pagamento único não determinados neste recorte.') +
' Preparação não é pagamento nem dedução anual.',
p.activityReference +
'; ' +
p.turnoverReference +
@@ -200,4 +447,6 @@ export function pecObligationReport(
];
}
function turnoverCorrectionNotice(c:{pending:string[]}|null){return c?.pending.join(' ')??'';}
function turnoverCorrectionNotice(c: { pending: string[] } | null) {
return c?.pending.join(' ') ?? '';
}
+50 -5
View File
@@ -1,3 +1,7 @@
import {
advanceCalendar,
type AdvanceFiscalPeriod,
} from './irpc-advance-calendar.ts';
import {
decimalFraction,
addFractions,
@@ -10,6 +14,8 @@ export const pecPlanSource =
'RIRPC29(1):mesesJunho/Agosto/Outubro e opção de pagamento único. Montantes repartidos e mês único são hipótese explícita do utilizador,sem regra de igualdade,arredondamento,guia ou prazo legal presumidos. Conferência de prazo da opção única permanece separada.';
export type PecPlan = {
version: 1;
periodFrom?: string;
periodTo?: string;
mode: '' | 'three' | 'single';
reference: string;
singleMonth: string;
@@ -17,7 +23,10 @@ export type PecPlan = {
august: string;
october: string;
};
export const newPecPlan = (): PecPlan => ({
export const newPecPlan = (period?: AdvanceFiscalPeriod): PecPlan => ({
...(period?.from && period?.to
? { periodFrom: period.from, periodTo: period.to }
: {}),
version: 1,
mode: '',
reference: '',
@@ -27,6 +36,13 @@ export const newPecPlan = (): PecPlan => ({
october: '',
});
export function validatePecPlan(p: PecPlan) {
if (
(p?.periodFrom !== undefined || p?.periodTo !== undefined) &&
(typeof p.periodFrom !== 'string' || typeof p.periodTo !== 'string')
)
throw Error(
'Contexto da hipótese PEC não recuperável;original preservado.',
);
if (
!p ||
p.version !== 1 ||
@@ -46,9 +62,19 @@ export function calculatePecPlan(
total: FiscalFraction | null,
notApplicable: boolean,
year: string,
period?: AdvanceFiscalPeriod,
) {
validatePecPlan(p);
const pending: string[] = [];
const calendar = advanceCalendar(period, year);
pending.push(...calendar.pending.map((p) => 'Projecção PEC: ' + p));
const needsPeriodConfirmation =
(calendar.mode === 'annual_non_civil' || p.periodFrom !== undefined) &&
(p.periodFrom !== calendar.from || p.periodTo !== calendar.to);
if (needsPeriodConfirmation)
pending.push(
'Projecção PEC: reconfira a distribuição no período actual;montantes e mês anterior conservados sem deslocação automática.',
);
let rows: Array<{ month: string; amount: FiscalFraction }> = [];
if (!total)
pending.push(
@@ -92,11 +118,15 @@ export function calculatePecPlan(
);
else
rows = amounts.map((amount, i) => ({
month: ['2026-06', '2026-08', '2026-10'][i],
month: calendar.pecMonths[i],
amount,
}));
}
} else if (!/^2026-(0[1-9]|1[0-2])$/.test(p.singleMonth))
} else if (
!/^20\d{2}-(0[1-9]|1[0-2])$/.test(p.singleMonth) ||
p.singleMonth < calendar.from.slice(0, 7) ||
p.singleMonth > calendar.to.slice(0, 7)
)
pending.push(
'Projecção PEC:indique o mês previsto do pagamento único;isto não valida o prazo legal.',
);
@@ -104,7 +134,21 @@ export function calculatePecPlan(
}
return {
version: pecPlanVersion,
source: pecPlanSource,
source:
calendar.mode === 'annual_non_civil'
? pecPlanSource.replace(
'mesesJunho/Agosto/Outubro',
'6.º/8.º/10.º mês do período anual não civil',
) +
' ' +
calendar.source
: pecPlanSource,
months: calendar.pecMonths,
needsPeriodConfirmation,
contextFrom: calendar.from,
contextTo: calendar.to,
calendarPending: calendar.mode === 'unavailable',
nonCivil: calendar.mode === 'annual_non_civil',
mode: p.mode,
pending,
rows,
@@ -120,8 +164,9 @@ export function pecPlanReport(
total: FiscalFraction | null,
notApplicable: boolean,
year: string,
period?: AdvanceFiscalPeriod,
): Array<[string, string, string, string]> {
const m = calculatePecPlan(p, total, notApplicable, year);
const m = calculatePecPlan(p, total, notApplicable, year, period);
return [
[
'IRPC — L8',
+58 -6
View File
@@ -6,6 +6,22 @@ import {
type FiscalFraction,
} from './fiscal-rational.ts';
export type { AdvanceFiscalPeriod } from './irpc-advance-calendar.ts';
import {
advanceCalendar,
type AdvanceFiscalPeriod,
} from './irpc-advance-calendar.ts';
export function ordinaryAdvancePeriodIssues(
period: AdvanceFiscalPeriod | undefined,
year: string,
component: 'PPC' | 'PEC' | 'PPC/PEC' = 'PPC/PEC',
subjectNuit?: string,
): string[] {
return advanceCalendar(period, year, subjectNuit).pending.map(
(message) => component + ': ' + message,
);
}
export const ppcObligationVersion = 'IRPC-PPC-obligation/1';
export const ppcObligationSource =
'RIRPC, Decreto 9/2008, arts.27(1)(a) e28(1–3), BR16/I,16-04-2008,exemplar preservado SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee: imposto liquidado no exercício anterior nos termos21(1), líquido das retenções na fonte;80%, dividido em três montantes iguais arredondados por excesso ao milhar de meticais. Maio,Julho,Setembro. Recorte2026,residente geral,actividade comercial/industrial/agrícola,ano civil e exercício anterior completo2025,MZN. Períodos especiais,anualização28(3),dispensas,juros e guias separados.';
@@ -57,16 +73,20 @@ export function calculatePpcObligation(
subjectNuit: string,
year: string,
assessment: string,
period?: AdvanceFiscalPeriod,
) {
validatePpcObligation(p);
const pending: string[] = [];
const calendar = advanceCalendar(period, year, subjectNuit);
const noncivilPreparation =
calendar.candidate && period?.declaration?.mode === 'annual_non_civil';
let base: FiscalFraction | null = null,
annualBeforeRounding: FiscalFraction | null = null,
instalment: FiscalFraction | null = null;
if (
p.scope !== 'ordinary_resident' ||
assessment !== 'normal' ||
p.previousFullCalendarYear !== 'yes' ||
p.previousFullCalendarYear !== (noncivilPreparation ? 'no' : 'yes') ||
year !== '2026' ||
p.year !== year ||
p.previousYear !== '2025' ||
@@ -74,7 +94,9 @@ export function calculatePpcObligation(
p.subjectNuit !== subjectNuit
)
pending.push(
'PPC: confirmar titular,2026,residente geral com actividade comercial/industrial/agrícola e exercício civil completo2025. Outros períodos/regimes e anualização permanecem separados.',
noncivilPreparation
? 'PPC: confirmar titular2026 e período anterior anual não civil com termo2025;indique Não em ano civil completo e confronte a correspondência documentada.'
: 'PPC: confirmar titular,2026,residente geral com actividade comercial/industrial/agrícola e exercício civil completo2025. Outros períodos/regimes e anualização permanecem separados.',
);
if (!p.assessmentReference.trim() || !p.withholdingReference.trim())
pending.push(
@@ -88,6 +110,17 @@ export function calculatePpcObligation(
pending.push(
'PPC: montantes anteriores em MZN desconhecidos ou inválidos; zero deve ser explícito.',
);
pending.push(
...ordinaryAdvancePeriodIssues(period, year, 'PPC', subjectNuit),
);
if (
calendar.mode === 'annual_non_civil' &&
(!period?.ppcBasisKey ||
period.declaration?.ppcBasisKey !== period.ppcBasisKey)
)
pending.push(
'PPC: montantes ou fontes alterados;reconfira a sua correspondência com o período anterior,sem substituir os valores.',
);
if (!pending.length) {
base = subtractFractions(
decimalFraction(p.previousTax),
@@ -109,7 +142,25 @@ export function calculatePpcObligation(
}
return {
version: ppcObligationVersion,
source: ppcObligationSource,
source:
calendar.mode === 'annual_non_civil'
? ppcObligationSource
.replace(
'ano civil e exercício anterior completo2025',
'períodos anuais completos não civis com termo2026/2025',
)
.replace(
'Maio,Julho,Setembro',
'5.º/7.º/9.º mês do período',
)
.replace(
'Períodos especiais,',
'Períodos curtos e outros regimes,',
) +
' ' +
calendar.source
: ppcObligationSource,
nonCivil: calendar.mode === 'annual_non_civil',
pending,
base,
annualBeforeRounding,
@@ -121,7 +172,7 @@ export function calculatePpcObligation(
)
: null,
schedule: instalment
? ['2026-05', '2026-07', '2026-09'].map((month) => ({
? calendar.ppcMonths.map((month) => ({
month,
amount: instalment,
}))
@@ -135,8 +186,9 @@ export function ppcObligationReport(
subjectNuit: string,
year: string,
assessment: string,
period?: AdvanceFiscalPeriod,
): Array<[string, string, string, string]> {
const m = calculatePpcObligation(p, subjectNuit, year, assessment),
const m = calculatePpcObligation(p, subjectNuit, year, assessment, period),
show = (v: FiscalFraction | null) =>
v ? fractionPresentation(v).decimal + ' MZN' : 'Indeterminado';
return [
@@ -157,7 +209,7 @@ export function ppcObligationReport(
m.schedule
.map((s) => s.month + ': ' + show(s.amount))
.join('; ') +
'. Previsão de obrigação; não é pagamento nem dedução anual.',
'. Estimativa fiscal para viabilidade; não é pagamento nem dedução anual.',
p.assessmentReference +
'; ' +
p.withholdingReference +
+140 -48
View File
@@ -13,6 +13,8 @@ export type PortalNavAccess = {
opportunities?: { index: string; create?: string | null };
};
scopedAdministration?: { label: string; href: string }[];
dashboardNavigation?: { label: string; href: string }[];
administrationNavigation?: { label: string; href: string }[];
procurement?: boolean;
funding?: boolean;
authenticated: boolean;
@@ -421,7 +423,9 @@ export function currentNavigationGroup(
for (const [key, value] of target.searchParams)
if (current.searchParams.get(key) !== value) return;
const score =
target.pathname.length + target.searchParams.size * 100;
target.pathname.length +
target.searchParams.size * 100 +
(target.hash ? 10000 : 0);
if (!match || score > match.score) match = { group, score };
});
for (const group of groups) visit(group.children ?? [], group.id);
@@ -448,7 +452,7 @@ export function currentNavigationItem(
)
)
continue;
const score = target.searchParams.size;
const score = target.searchParams.size + (target.hash ? 10000 : 0);
if (score > best.score) {
best.id = item.id;
best.score = score;
@@ -477,6 +481,126 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
? access.opportunityManagement.create.href
: null)
: null;
const dashboardActivities = access.authenticated
? access.dashboardNavigation?.length
? access.dashboardNavigation
: previous
.filter((group) => group.id === 'account')
.flatMap((group) => group.children ?? [])
.filter((item) => item.href === '/dashboard')
.map((item) => ({ label: 'Visão geral', href: item.href! }))
: [];
const flatten = (items: PortalNavItem[]): PortalNavItem[] =>
items.flatMap((item) =>
item.children ? flatten(item.children) : [item],
);
const profileAdministration = new Set([
'profile-administration',
'profile-installation',
'profile-audit',
]);
const candidates = access.authenticated
? [
...(workHub
? [link('work-entry', 'Gestão de conteúdos', workHub)]
: []),
...(workCreate
? [
link(
'prepare-analysis',
'Preparar análise e ideia',
workCreate + '?analysis=proactive',
),
]
: []),
...(workOpportunities
? [
link(
'work-opportunities',
'Oportunidades em preparação',
workOpportunities.index,
),
]
: []),
...(access.administrationNavigation ?? []).map((item, index) =>
link(
'administrative-activity-' + index,
item.label,
item.href,
),
),
...flatten(
previous
.filter((group) => group.id === 'management')
.flatMap((group) => group.children ?? []),
),
...(access.scopedAdministration ?? []).map((item, index) =>
link('scoped-' + index, item.label, item.href),
),
...previous
.filter((group) => group.id === 'profile-opportunities')
.flatMap((group) => group.children ?? [])
.filter((item) => profileAdministration.has(item.id)),
]
: [];
const categories = [
{
id: 'administration-content',
label: 'Conteúdos e oportunidades',
children: [] as PortalNavItem[],
},
{
id: 'administration-records',
label: 'Cadastros e investimento',
children: [] as PortalNavItem[],
},
{
id: 'administration-followup',
label: 'Pedidos e acompanhamento',
children: [] as PortalNavItem[],
},
{
id: 'administration-access',
label: 'Acessos e configuração',
children: [] as PortalNavItem[],
},
];
for (const item of candidates) {
const path = new URL(item.href!, 'https://portal.invalid').pathname;
const category =
/^\/(gestao-conteudos|management\/(portal-services|opportunities|concursos)|minhas-oportunidades)(\/|$)/.test(
path,
)
? 0
: /^\/management\/(interests|crm|balcao\/atribuicoes)(\/|$)/.test(
path,
)
? 2
: profileAdministration.has(item.id) ||
/-install$/.test(path) ||
path === '/management/ai'
? 3
: 1;
categories[category].children.push(item);
}
const administration = [
...(dashboardActivities.length
? [
{
id: 'collaborative-dashboard',
label: 'Dashboard',
children: dashboardActivities.map((item, index) =>
link(
'dashboard-activity-' + index,
item.label,
item.href,
),
),
},
]
: []),
...categories.filter((group) => group.children.length > 0),
];
return uniqueNavigationDestinations([
{
id: 'discovery',
@@ -603,58 +727,26 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
],
},
...(administration.length
? [
{
id: 'administration',
label: 'Administração',
children: administration,
},
]
: []),
{
id: 'work',
label: 'Área de trabalho',
children: [
link(
'work-entry',
workHub
? 'Gestão de conteúdos e oportunidades'
: 'Acesso à gestão',
workHub || '/acesso-gestao',
),
...(workCreate
? [
link(
'prepare-analysis',
'Preparar análise e ideia',
workCreate + '?analysis=proactive',
),
]
: []),
...(workOpportunities
? [
link(
'work-opportunities',
'Oportunidades em preparação',
workOpportunities.index,
),
]
...(!workHub
? [link('work-entry', 'Acesso à gestão', '/acesso-gestao')]
: []),
...previous
.filter((group) =>
['management', 'profile-opportunities'].includes(
group.id,
),
)
.flatMap((group) => group.children ?? []),
...(access.authenticated && access.scopedAdministration?.length
? [
{
id: 'work-administration',
label: 'Administração',
children: access.scopedAdministration.map(
(item, index) =>
link(
'scoped-' + index,
item.label,
item.href,
),
),
},
]
: []),
.filter((group) => group.id === 'profile-opportunities')
.flatMap((group) => group.children ?? [])
.filter((item) => !profileAdministration.has(item.id)),
],
},
...previous.filter((group) => group.id === 'account'),
@@ -396,6 +396,11 @@ export async function generateProjectDossierDocx(
],
}),
];
for (const limitation of model.limitations) {
children.push(
new Paragraph({ children: [new TextRun({ text: limitation })] }),
);
}
for (const section of model.sections) {
if (section.id === 'annexes') {
if (!attachments.some((item) => item.include)) {
@@ -591,6 +596,8 @@ export async function createProjectDossierPrintHtml(
annexesIncluded: boolean,
): Promise<string> {
const content: string[] = [];
for (const limitation of model.limitations)
content.push(`<p>${escapeHtml(limitation)}</p>`);
for (const section of model.sections) {
if (
section.id === 'annexes' &&
+48 -5
View File
@@ -36,10 +36,12 @@ export default function Dashboard({
investorPath,
opportunityManagement,
workQueue,
editorialQueue,
}: {
summary: Summary | null;
opportunityManagement?: OpportunityManagementAccess | null;
workQueue?: DashboardQueue | null;
editorialQueue?: { pending: number; unread: number } | null;
investorPath?: {
contexts: InvestmentContext[];
current: {
@@ -58,7 +60,35 @@ export default function Dashboard({
<h1 className="text-2xl font-semibold">Painel do portal</h1>
<ProfileNavigation />
<ContentManagementEntry />
<DashboardWorkQueue queue={workQueue} />
<section id="meu-trabalho" className="scroll-mt-24">
{workQueue ? (
<DashboardWorkQueue queue={workQueue} />
) : (
<>
<h2 className="text-lg font-semibold">
O meu trabalho
</h2>
<p className="text-muted-foreground text-sm">
Sem fila de trabalho disponível neste âmbito.
</p>
</>
)}
</section>
{editorialQueue && (
<section
className="rounded-lg border p-4"
aria-label="Trabalho editorial"
>
<h2 className="font-semibold">Circuito editorial</h2>
<p>
{editorialQueue.pending} conteúdos pendentes ·{' '}
{editorialQueue.unread} notificações por ler
</p>
<Link className="underline" href="/management/editorial">
Abrir fila editorial e histórico
</Link>
</section>
)}
{summary ? (
<>
<Link
@@ -111,7 +141,10 @@ export default function Dashboard({
</p>
</section>
)}
<section className="space-y-4">
<section
id="manifestacoes"
className="scroll-mt-24 space-y-4"
>
<h2 className="text-lg font-semibold">
Manifestações de interesse
</h2>
@@ -132,7 +165,10 @@ export default function Dashboard({
))}
</div>
</section>
<section className="space-y-4">
<section
id="indicadores-oportunidades"
className="scroll-mt-24 space-y-4"
>
<h2 className="text-lg font-semibold">
<Link href="/management/opportunities">
{opportunityManagement
@@ -173,7 +209,10 @@ export default function Dashboard({
))}
</div>
</section>
<section className="space-y-4">
<section
id="pedidos-recentes"
className="scroll-mt-24 space-y-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold">
Pedidos mais recentes
@@ -226,6 +265,7 @@ export default function Dashboard({
)}
</section>
<nav
id="accoes-rapidas"
aria-label="Acções rápidas"
className="flex flex-wrap gap-4"
>
@@ -247,7 +287,10 @@ export default function Dashboard({
</nav>
</>
) : investorPath ? (
<section className="space-y-4 rounded-xl border p-6">
<section
id="meus-investimentos"
className="scroll-mt-24 space-y-4 rounded-xl border p-6"
>
<h2 className="text-lg font-semibold">
Os meus investimentos
</h2>
+239 -13
View File
@@ -89,6 +89,15 @@ const labels: Record<string, string> = {
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 (
<section className={card}>
<h2 className="text-xl font-bold">Histórico editorial</h2>
{revisions.map((r) => (
<p key={r.version_no}>
Versão {r.version_no} · {r.action} · {r.created_at}{' '}
<button
type="button"
className={button}
disabled={loading}
onClick={() => inspect(r.version_no)}
>
Conferir versão {r.version_no}
</button>
</p>
))}
{error && <p role="alert">{error}</p>}
{selected && (
<div className="space-y-2 border-t pt-3">
<h3 className="font-bold">
Consulta da versão {selected.version_no}
</h3>
<p>
Esta consulta preserva a versão actual e a preparação em
curso.
</p>
<p>{selected.fields.name}</p>
<p className="whitespace-pre-line">
{selected.fields.description}
</p>
{selected.observations.map((row, index) => (
<div key={index}>
<strong>{row.label}</strong>
<p>
{row.value} {row.unit}
</p>
<p>
{row.scope} · {row.period_start} –{' '}
{row.period_end}
</p>
<p>{row.source_reference}</p>
</div>
))}
</div>
)}
</section>
);
}
function ZoneReview({
zone,
busy,
dirty,
action,
}: {
zone: Zone;
busy: boolean;
dirty: boolean;
action: (url: string, data: unknown) => Promise<void>;
}) {
const [reason, setReason] = useState('');
const workflow = zone.editorial_workflow;
const labels: Record<string, string> = {
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<string, string> = {
draft: 'Rascunho',
submitted: 'Submetido',
in_review: 'Em revisão',
approved: 'Aprovado',
returned: 'Devolvido',
rejected: 'Rejeitado',
archived: 'Arquivado',
};
const actions: Record<string, string[]> = {
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 (
<section className={card}>
<h2 className="text-xl font-bold">
Revisão da zona e dos indicadores
</h2>
<p>
{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.
</p>
{workflow && (
<p>
Edição do conteúdo: {workflow.content_version} · versão de
trabalho: {zone.version_no}.
</p>
)}
{workflow && !workflow.fresh && (
<p role="alert">
O conteúdo, fonte ou anexos mudaram. Guarde uma nova
preparação e volte a conferir.
</p>
)}
{dirty && (
<p>Guarde as alterações antes de decidir sobre a edição.</p>
)}
<Field label="Fundamento da decisão">
<textarea
className={input}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</Field>
<div className="mt-3 flex flex-wrap gap-3">
{(actions[workflow?.state ?? 'not_started'] ?? [])
.filter(
(name) => name !== 'approve' || workflow?.can_approve,
)
.map((name) => (
<button
key={name}
className={button}
type="button"
disabled={
busy ||
dirty ||
reason.trim().length < 10 ||
Boolean(
workflow &&
!workflow.fresh &&
['submit', 'approve'].includes(name),
)
}
onClick={() =>
void action(
`/api/v2/investment/admin/zones/${zone.id}/editorial/${name}`,
{ version_no: zone.version_no, reason },
)
}
>
{labels[name]}
</button>
))}
</div>
</section>
);
}
export default function ZonePage(props: Props) {
return (
<Editor
@@ -340,8 +549,19 @@ function Editor({
setBusy(true);
setMessage('');
try {
await api(url, data);
router.reload();
const result = await api(url, data);
if (
result &&
typeof result === 'object' &&
typeof result.editorial_url === 'string' &&
result.editorial_url.startsWith(
'/management/editorial/subjects/',
)
) {
router.visit(result.editorial_url);
} else {
router.reload();
}
} catch (e) {
setMessage(e instanceof Error ? e.message : 'Falha na operação.');
} finally {
@@ -1342,6 +1562,14 @@ function Editor({
Guardar outra localização
</button>
</section>
{z.editorial_enabled && (
<ZoneReview
zone={z}
busy={busy}
dirty={form.isDirty}
action={action}
/>
)}
<section className={card}>
<h2 className="text-xl font-bold">
Publicação informativa
@@ -1359,6 +1587,14 @@ function Editor({
disabled={
busy ||
form.isDirty ||
(z.editorial_enabled &&
(z.editorial_workflow?.state !==
'approved' ||
!z.editorial_workflow?.fresh ||
(z.editorial_workflow
?.independent_required &&
!z.editorial_workflow
?.can_approve))) ||
(z.category === 'industrial_park' &&
!profileAvailable)
}
@@ -1518,17 +1754,7 @@ function Editor({
</div>
))}
</section>
<section className={card}>
<h2 className="text-xl font-bold">
Histórico editorial
</h2>
{revisions.map((r) => (
<p key={r.version_no}>
Versão {r.version_no} · {r.action} ·{' '}
{r.created_at}
</p>
))}
</section>
<ZoneHistory id={z.id} revisions={revisions} />
{targets && (
<ZoneRelations
id={z.id}
@@ -3,6 +3,10 @@ import { Frame, card, button } from '@/pages/investment/shared';
import { states } from './shared';
type Access = {
demography?: { index: string };
portfolio?: { index: string };
clusters?: { index: string };
editorial?: { index: string };
procurement?: { index: string; create: string };
opportunities?: {
index: string;
@@ -26,6 +30,65 @@ export default function ContentHub({
acção respeita a sua autorização actual.
</p>
<div className="grid gap-4 md:grid-cols-2">
{access.demography && (
<section className={card}>
<h2 className="text-xl font-semibold">
Indicadores demográficos
</h2>
<p>
Conferir originais, observações e correspondências
dos novos lotes. Rever, aprovar e publicar
separadamente, conservando os dados anteriormente
disponibilizados.
</p>
<Link className={button} href={access.demography.index}>
Gerir lotes e revisões
</Link>
</section>
)}
{access.portfolio && (
<section className={card}>
<h2 className="text-xl font-semibold">
Carteira de investimento
</h2>
<p>
Rever versões das fichas de projectos, clusters e
corredores, preservando a publicação anterior.
</p>
<Link className={button} href={access.portfolio.index}>
Gerir fichas e revisões da carteira
</Link>
</section>
)}
{access.clusters && (
<section className={card}>
<h2 className="text-xl font-semibold">
Competitividade dos clusters
</h2>
<p>
Preparar a análise, submeter, rever e aprovar uma
edição. Publicar é uma operação separada e preserva
a análise anterior até à confirmação.
</p>
<Link className={button} href={access.clusters.index}>
Gerir análises dos clusters
</Link>
</section>
)}
{access.editorial && (
<section className={card}>
<h2 className="text-xl font-semibold">
Circuito editorial
</h2>
<p>
Preparar, rever, aprovar e publicar versões de
artigos, legislação e documentos.
</p>
<Link className={button} href={access.editorial.index}>
Abrir fila editorial
</Link>
</section>
)}
{access.procurement && (
<section className={card}>
<h2 className="text-xl font-semibold">
+98 -33
View File
@@ -1,4 +1,4 @@
import { router } from '@inertiajs/react';
import { Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { Management, button, control } from './shared';
@@ -38,7 +38,11 @@ export default function DemographyManagement({
batches,
preview,
qa = true,
editorialEnabled = false,
editorialRevision = 0,
}: {
editorialEnabled?: boolean;
editorialRevision?: number;
qa?: boolean;
batches: {
id: string;
@@ -55,6 +59,7 @@ export default function DemographyManagement({
const [mapping, setMapping] = useState<Record<string, string>>({});
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const review = useForm({ expected_revision: editorialRevision });
async function execute(path: string, body: FormData | object) {
setBusy(true);
setMessage('');
@@ -77,7 +82,11 @@ export default function DemographyManagement({
},
body: body instanceof FormData ? body : JSON.stringify(body),
});
const data = await response.json();
const data = await response.json().catch(() => null);
if (!data)
throw new Error(
'O servidor não confirmou a operação. Confira o estado guardado antes de repetir.',
);
if (!response.ok)
throw new Error(
data.message ?? 'Não foi possível executar a operação.',
@@ -100,8 +109,10 @@ export default function DemographyManagement({
>
<p>
Preparar não grava observações. Confirmar importa para consulta
administrativa. Aprovar disponibiliza a série nas análises deste
ambiente; desactivar conserva dados e histórico.
administrativa.{' '}
{editorialEnabled
? 'A revisão, a aprovação e a publicação dos novos lotes são operações distintas. Os lotes anteriormente disponibilizados permanecem preservados.'
: 'Aprovar disponibiliza a série nas análises deste ambiente; desactivar conserva dados e histórico.'}
</p>
<form
className="space-y-3"
@@ -257,36 +268,90 @@ export default function DemographyManagement({
</button>
) : (
<>
{preview.status !== 'approved' && (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{ action: 'approved', reason },
)
}
>
{qa
? 'Aprovar utilização em QA'
: 'Aprovar utilização'}
</button>
)}
{preview.status !== 'disabled' && (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{ action: 'disabled', reason },
)
}
>
Desactivar série
</button>
{editorialEnabled && (
<div className="space-y-2">
{editorialRevision > 0 && (
<Link
className="underline"
href={`/management/editorial/demography/${preview.id}`}
>
Continuar revisão e consultar
versões
</Link>
)}
{preview.status === 'approved' ? (
<p>
{editorialRevision
? 'Lote publicado; consulte a edição e o histórico.'
: 'Lote anteriormente disponibilizado, preservado sem nova aprovação.'}
</p>
) : (
<button
type="button"
className={button}
disabled={
busy || review.processing
}
onClick={() => {
review.transform(() => ({
expected_revision:
editorialRevision,
}));
review.post(
`/management/editorial/demography/${preview.id}/drafts`,
);
}}
>
{editorialRevision
? 'Preparar nova edição de revisão'
: 'Preparar revisão para publicação'}
</button>
)}
</div>
)}
{!editorialEnabled &&
preview.status !== 'approved' && (
<button
disabled={
busy || reason.length < 10
}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{
action: 'approved',
reason,
},
)
}
>
{qa
? 'Aprovar utilização em QA'
: 'Aprovar utilização'}
</button>
)}
{preview.status !== 'disabled' &&
(!editorialEnabled ||
editorialRevision === 0) && (
<button
disabled={
busy || reason.length < 10
}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{
action: 'disabled',
reason,
},
)
}
>
Desactivar série
</button>
)}
</>
)}
</div>
@@ -0,0 +1,615 @@
import { Link, useForm } from '@inertiajs/react';
import { Management, Field, button, control } from './shared';
import { states } from '@/lib/editorial-states';
import { labels as portfolioLabels } from '../portfolio/shared';
import SubjectForm from './editorial-subject-form';
import type { SubjectField } from './editorial-subject-form';
type Edition = {
id: string;
revision_no: number;
version: number;
state: string;
content_hash: string;
legal_required: boolean;
fresh: boolean;
payload: {
resource: {
title: string;
description?: string;
editorial_content?: string | null;
source?: string;
publication_version?: string;
observations?: number;
sha256?: string;
dataset_hash?: string;
report?: { issue_counts?: Record<string, number> };
content?: Record<string, string | null>;
_structure?: {
fields: Record<string, string | number | null>;
territory_context?: { id: string; name: string }[];
};
_subject?: {
kind: string;
key: string;
fields: Record<string, string | number | boolean | null>;
new_record: boolean;
withdraw?: boolean;
};
};
files: {
id: string;
file_name: string;
version_no: number;
sha256: string;
}[];
links?: {
target_type: string;
target_key: string;
context?: { name?: string; code?: string; sector?: string };
}[];
};
approval: string | null;
legal_validation: string | null;
};
export default function EditorialEdition({
resource,
edition,
manager,
legalReviewer,
history,
editions,
reviewers,
current,
canApprove,
independentRequired,
subject = 'resource',
subjectKind,
editUrl,
subjectFields = [],
subjectInitial = {},
}: {
subject?: 'resource' | 'portfolio' | 'demography' | 'structural';
subjectKind?: string;
editUrl?: string;
subjectFields?: SubjectField[];
subjectInitial?: Record<string, string | number | boolean | null>;
resource: {
id: number | string;
title: string;
published: boolean;
public_edition_no?: number | null;
};
edition: Edition | null;
manager: boolean;
legalReviewer: boolean;
history: {
id: number;
action: string;
actor_user_id: number;
after_data: string;
occurred_at: string;
}[];
editions: { id: string; revision_no: number; state: string }[];
reviewers: { id: number; name: string }[];
current: boolean;
canApprove: boolean;
independentRequired: boolean;
}) {
const editionBase =
subject === 'structural'
? `/management/editorial/subjects/${subjectKind}/${resource.id}`
: `/management/editorial/${subject === 'resource' ? 'resources' : subject}/${resource.id}`;
const form = useForm({
version: edition?.version ?? 1,
action: '',
reason: '',
assigned_to: '',
});
const available: Record<string, string[]> = {
draft: ['submit', 'archive'],
returned: ['submit', 'archive'],
submitted: ['start_review', 'archive'],
in_review: ['return', 'reject', 'legal_validate', 'approve', 'archive'],
approved: ['publish', 'archive'],
rejected: ['archive'],
published: ['archive'],
archived: [],
};
const labels: Record<string, string> = {
submit: 'Submeter',
start_review: 'Iniciar revisão',
return: 'Devolver',
reject: 'Rejeitar',
legal_validate: 'Validar juridicamente',
approve: 'Aprovar',
publish: edition?.payload.resource._subject?.withdraw
? 'Publicar retirada'
: 'Publicar',
archive: 'Arquivar',
};
return (
<Management title={`Edição — ${resource.title}`}>
<div className="flex flex-wrap gap-4">
<Link className="underline" href="/management/editorial">
Fila e notificações
</Link>
{subject === 'structural' ? (
<Link
className="underline"
href={editUrl ?? '/management/editorial/subjects'}
>
Alterar no contexto original
</Link>
) : subject === 'demography' ? (
<Link
className="underline"
href={`/management/demography?batch=${resource.id}`}
>
Conferir lote, correspondências e advertências
</Link>
) : subject === 'portfolio' ? (
<Link
className="underline"
href={`/management/portfolio/${resource.id}`}
>
Alterar ficha da carteira
</Link>
) : (
<>
<Link
className="underline"
href={`/management/portal-services/resources/${resource.id}/article`}
>
Alterar artigo
</Link>
</>
)}
{subject === 'resource' && (
<Link
className="underline"
href="/management/portal-services"
>
Alterar metadados/documento
</Link>
)}
</div>
<p>
{resource.published
? 'A versão pública continua disponível até uma publicação ou retirada explícita.'
: 'Ainda não existe uma versão pública disponível.'}
</p>
{resource.public_edition_no && (
<p>Versão pública: edição {resource.public_edition_no}.</p>
)}
{!edition && subject === 'structural' && (
<SubjectForm
base={editionBase}
revision={1}
fields={subjectFields}
initial={subjectInitial}
/>
)}
{edition ? (
<>
<h2 className="font-semibold">
Edição {edition.revision_no} ·{' '}
{states[edition.state] ?? edition.state}
</h2>
{!edition.fresh && (
<p role="alert">
O conteúdo, contexto ou anexos mudaram ou deixaram
de estar disponíveis. Guarde uma nova edição e volte
a conferir antes da aprovação.
</p>
)}
<p className="text-sm break-all">
Identificador da edição: {edition.id}
<br />
Hash do conteúdo e{' '}
{['portfolio', 'structural'].includes(subject)
? 'contexto'
: 'anexos'}
: {edition.content_hash}
</p>
<h3 className="font-semibold">Conferir conteúdo</h3>
<p>{edition.payload.resource.title}</p>
{edition.payload.resource._subject?.withdraw && (
<p role="status">
Proposta de retirada: o registo actual só será
retirado depois da publicação desta decisão.
</p>
)}
{subject === 'structural' &&
edition.payload.resource._subject && (
<>
<dl className="grid gap-2 sm:grid-cols-2">
{subjectFields.map((field) => {
const value =
edition.payload.resource._subject
?.fields[field.name];
return (
<div key={field.name}>
<dt className="font-medium">
{field.label}
</dt>
<dd className="break-words whitespace-pre-wrap">
{value === null ||
value === '' ||
value === undefined
? 'Não indicado'
: typeof value ===
'boolean'
? value
? 'Sim'
: 'Não'
: String(value)}
</dd>
</div>
);
})}
</dl>
{current && (
<SubjectForm
key={edition.id}
base={editionBase}
revision={edition.revision_no}
fields={subjectFields}
initial={
edition.payload.resource._subject
.fields
}
withdraw={
edition.payload.resource._subject
.withdraw
}
/>
)}
</>
)}
<p className="whitespace-pre-wrap">
{edition.payload.resource.description}
</p>
{subject === 'demography' && (
<section className="space-y-2 rounded border p-3">
<p>Fonte: {edition.payload.resource.source}</p>
<p>
Exemplar/versão fornecida:{' '}
{edition.payload.resource.publication_version}
</p>
<p>
{edition.payload.resource.observations}{' '}
observações conferidas, sem alteração dos
valores originais.
</p>
<a
className="underline"
href={`${editionBase}/original`}
>
Consultar original fornecido
</a>
<p className="text-sm break-all">
SHA256 do original:{' '}
{edition.payload.resource.sha256}
</p>
<p className="text-sm break-all">
Integridade das observações:{' '}
{edition.payload.resource.dataset_hash}
</p>
<ul>
{Object.entries(
edition.payload.resource.report
?.issue_counts ?? {},
).map(([code, count]) => (
<li key={code}>
{code}: {count} advertências conservadas
</li>
))}
</ul>
<p>
Aprovar esta edição não disponibiliza a série.
Confirme publicar numa operação separada.
</p>
{edition.payload.links?.map((link) => (
<p key={link.target_key}>
Território: {link.context?.name}
</p>
))}
</section>
)}
{subject === 'portfolio' && (
<>
<p>Fonte: {edition.payload.resource.source}</p>
<dl>
{Object.entries(
edition.payload.resource.content ?? {},
).map(([key, value]) => (
<div key={key}>
<dt className="font-semibold">
{portfolioLabels[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap">
{value}
</dd>
</div>
))}
</dl>
</>
)}
{subject === 'portfolio' &&
edition.payload.resource._structure && (
<section className="rounded border p-3">
<h3 className="font-semibold">
Alterações propostas nos dados-base
</h3>
<dl>
{Object.entries(
edition.payload.resource._structure
.fields,
)
.filter(
([key]) =>
![
'primary_territory_id',
'territory_version',
].includes(key),
)
.map(([key, value]) => (
<div key={key}>
<dt className="font-semibold">
{(
{
name: 'Designação',
code: 'Código',
status: 'Estado operacional',
sector: 'Sector',
description:
'Descrição',
budget_amount:
'Orçamento',
budget_currency:
'Moeda',
start_date:
'Data inicial',
end_date:
'Data final',
} as Record<
string,
string
>
)[key] ?? key}
</dt>
<dd>
{value ?? 'Por indicar'}
</dd>
</div>
))}
</dl>
{'primary_territory_id' in
edition.payload.resource._structure
.fields && (
<p>
Localização proposta:{' '}
{edition.payload.resource._structure.territory_context
?.map((node) => node.name)
.join(' / ') ||
'Sem localização associada'}
</p>
)}
</section>
)}
{subject === 'portfolio' &&
edition.payload.links?.map((link) => (
<p key={link.target_key}>
Contexto: {link.context?.name} ·{' '}
{link.context?.code}
{link.context?.sector
? ` · ${link.context.sector}`
: ''}
</p>
))}
{edition.payload.resource.editorial_content && (
<p className="whitespace-pre-wrap">
{
(
JSON.parse(
edition.payload.resource
.editorial_content,
) as { body?: string }
).body
}
</p>
)}
<ul>
{edition.payload.files.map((file) => (
<li key={file.id}>
<a
className="underline"
href={`/management/editorial/resources/${resource.id}/editions/${edition.id}/files/${file.id}`}
target="_blank"
rel="noopener noreferrer"
>
{file.file_name} · versão {file.version_no}
</a>
<span className="block text-xs break-all">
SHA256: {file.sha256}
</span>
</li>
))}
</ul>
<p>
{edition.legal_required
? 'Validação jurídica obrigatória para esta edição.'
: 'Sem validação jurídica obrigatória definida para este tipo de conteúdo.'}
</p>
<p>
{edition.state === 'published' && !edition.approval
? 'Publicação anterior conservada, sem fabricar novas aprovações.'
: `Aprovação: ${edition.approval ? 'Registada nesta edição' : 'Pendente'}`}{' '}
· Validação jurídica:{' '}
{edition.legal_validation
? 'Registada nesta edição'
: 'Não registada'}
</p>
{independentRequired && current && (
<p>
Esta edição exige um responsável independente para a
aprovação.
</p>
)}
{current ? (
<form
key={edition.version}
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
version: edition.version,
assigned_to: data.assigned_to || null,
}));
form.post(
`${editionBase}/editions/${edition.id}`,
{
onSuccess: () =>
form.reset('action', 'reason'),
},
);
}}
className="space-y-3"
>
<Field label="Operação">
<select
required
className={control}
value={form.data.action}
onChange={(e) =>
form.setData('action', e.target.value)
}
>
<option value="">Seleccionar</option>
{(available[edition.state] ?? [])
.filter(
(action) =>
(manager ||
[
'submit',
'archive',
].includes(action)) &&
(action !== 'legal_validate' ||
(legalReviewer &&
canApprove)) &&
(action !== 'approve' ||
canApprove) &&
(![
'portfolio',
'structural',
].includes(subject) ||
action !== 'publish' ||
canApprove) &&
(edition.fresh ||
[
'archive',
'return',
'reject',
'start_review',
].includes(action)),
)
.map((action) => (
<option key={action} value={action}>
{labels[action]}
</option>
))}
</select>
</Field>
{form.data.action === 'submit' && (
<Field label="Responsável pela revisão">
<select
className={control}
value={form.data.assigned_to}
onChange={(e) =>
form.setData(
'assigned_to',
e.target.value,
)
}
>
<option value="">Por atribuir</option>
{reviewers.map((reviewer) => (
<option
key={reviewer.id}
value={reviewer.id}
>
{reviewer.name}
</option>
))}
</select>
</Field>
)}
<Field label="Fundamento">
<textarea
required
minLength={3}
maxLength={5000}
className={control}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</Field>
{Object.entries(form.errors).map(([key, error]) => (
<p key={key} role="alert">
{error}
</p>
))}
<button
className={button}
disabled={form.processing || !form.data.action}
>
Registar operação
</button>
</form>
) : (
<p>
Versão histórica para consulta.{' '}
<Link className="underline" href={editionBase}>
Voltar à edição actual
</Link>
</p>
)}
</>
) : (
<p>
{subject === 'demography' && resource.published
? 'Este lote já estava disponibilizado e permanece preservado. Uma nova fonte deve entrar como um lote próprio.'
: 'Prepare uma edição no editor existente para iniciar a revisão.'}
</p>
)}
<h2 className="font-semibold">Versões conservadas</h2>
<ul>
{editions.map((e) => (
<li key={e.id}>
<Link
className="underline"
href={`${editionBase}?edition=${e.id}`}
>
Edição {e.revision_no}: {states[e.state] ?? e.state}
</Link>
</li>
))}
</ul>
<h2 className="font-semibold">Histórico de operações</h2>
<ul>
{history.map((event) => (
<li key={event.id}>
{event.occurred_at} ·{' '}
{labels[event.action.replace('editorial_', '')] ??
'Edição guardada'}{' '}
· Utilizador {event.actor_user_id}
<p>{JSON.parse(event.after_data)?.reason}</p>
</li>
))}
</ul>
</Management>
);
}
@@ -0,0 +1,290 @@
import { useEffect, useState } from 'react';
import { useForm } from '@inertiajs/react';
import { portalRequest } from '@/lib/portal-api';
import { Field, button, control } from './shared';
export type SubjectField = {
name: string;
label: string;
type: string;
options: string[];
};
type Value = string | number | boolean | null;
const names: Record<string, string> = {
draft: 'Rascunho',
planned: 'Planeado',
active: 'Activo',
suspended: 'Suspenso',
completed: 'Concluído',
cancelled: 'Cancelado',
inactive: 'Inactivo',
proposed: 'Proposto',
closed: 'Encerrado',
input: 'Insumos',
production: 'Produção',
processing: 'Transformação',
logistics: 'Logística',
market: 'Mercado',
supplies: 'Fornece',
processes: 'Transforma',
distributes: 'Distribui',
supports: 'Apoia',
official: 'Oficial',
influence: 'Influência',
proximity: 'Proximidade',
potential: 'Potencial',
historical: 'Histórica',
anchor: 'Âncora',
complementary: 'Complementar',
supporting: 'Apoio',
primary: 'Principal',
beneficiary: 'Beneficiária',
served: 'Servida',
enabled: 'Viabilizada',
dependent: 'Dependente',
related: 'Relacionada',
identified: 'Identificada',
in_review: 'Em análise',
resolved: 'Resolvida',
withdrawn: 'Retirada',
};
function Reference({
base,
field,
value,
onChange,
}: {
base: string;
field: SubjectField;
value: Value;
onChange: (value: Value) => void;
}) {
const [query, setQuery] = useState('');
const [items, setItems] = useState<{ id: string; name: string }[]>([]);
const [message, setMessage] = useState('');
useEffect(() => {
const abort = new AbortController();
const timer = setTimeout(() => {
void portalRequest<{ data: { id: string; name: string }[] }>(
`${base}/choices?${new URLSearchParams({ field: field.name, q: query })}`,
{ signal: abort.signal },
)
.then((data) => {
setItems(data.data);
setMessage('');
})
.catch((error) => {
if (!abort.signal.aborted)
setMessage(
error instanceof Error
? error.message
: 'Não foi possível consultar o catálogo.',
);
});
}, 250);
return () => {
clearTimeout(timer);
abort.abort();
};
}, [base, field.name, query]);
return (
<div className="space-y-2">
<input
className={control}
aria-label={`Pesquisar ${field.label.toLowerCase()}`}
placeholder="Pesquisar no catálogo existente"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<select
className={control}
aria-label={field.label}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value || null)}
>
<option value="">Sem selecção</option>
{value && !items.some((item) => item.id === value) && (
<option value={String(value)}>
Selecção actual preservada
</option>
)}
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
{items.length === 100 && (
<p className="text-sm">
Refine a pesquisa para consultar outros resultados.
</p>
)}
{message && <p role="status">{message}</p>}
</div>
);
}
export default function SubjectForm({
base,
revision,
fields,
initial,
withdraw = false,
}: {
base: string;
revision: number;
fields: SubjectField[];
initial: Record<string, Value>;
withdraw?: boolean;
}) {
const [open, setOpen] = useState(false);
const form = useForm({
expected_revision: revision,
withdraw,
fields: Object.fromEntries(
fields.map((field) => [field.name, initial[field.name] ?? null]),
) as Record<string, Value>,
});
const set = (field: string, value: Value) =>
form.setData('fields', { ...form.data.fields, [field]: value });
return (
<details
className="rounded border p-4"
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary className="cursor-pointer font-medium underline">
Alterar proposta
</summary>
{open && (
<form
className="mt-4 space-y-4"
onSubmit={(event) => {
event.preventDefault();
form.post(`${base}/drafts`, { preserveScroll: true });
}}
>
<p>
Guarde uma nova edição. As decisões anteriores mantêm-se
no histórico e não aprovam automaticamente esta
alteração.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.withdraw}
onChange={(event) =>
form.setData('withdraw', event.target.checked)
}
/>
Propor a retirada deste registo. A situação actual
mantém-se até à publicação expressa da decisão.
</label>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<Field
key={field.name}
label={field.label}
error={
(form.errors as Record<string, string>)[
`fields.${field.name}`
] ??
(form.errors as Record<string, string>)[
field.name
]
}
>
{field.type === 'reference' ? (
<Reference
base={base}
field={field}
value={form.data.fields[field.name]}
onChange={(value) =>
set(field.name, value)
}
/>
) : field.type === 'enum' ? (
<select
className={control}
value={String(
form.data.fields[field.name] ?? '',
)}
onChange={(event) =>
set(field.name, event.target.value)
}
>
<option value="">Seleccionar</option>
{field.options.map((option) => (
<option key={option} value={option}>
{names[option] ?? option}
</option>
))}
</select>
) : field.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(
form.data.fields[field.name],
)}
onChange={(event) =>
set(
field.name,
event.target.checked,
)
}
/>
) : field.type === 'date' ? (
<input
className={control}
type="date"
value={String(
form.data.fields[field.name] ?? '',
).slice(0, 10)}
onChange={(event) =>
set(
field.name,
event.target.value || null,
)
}
/>
) : (
<textarea
className={control}
rows={
field.name === 'description' ||
field.name === 'rationale'
? 4
: 2
}
value={String(
form.data.fields[field.name] ?? '',
)}
onChange={(event) =>
set(
field.name,
event.target.value || null,
)
}
/>
)}
</Field>
))}
</div>
{Object.entries(form.errors)
.filter(
([key]) =>
key === 'expected_revision' || key === 'fields',
)
.map(([key, message]) => (
<p role="alert" key={key}>
{message}
</p>
))}
<button className={button} disabled={form.processing}>
Guardar nova edição privada
</button>
</form>
)}
</details>
);
}
@@ -0,0 +1,75 @@
import { Link } from '@inertiajs/react';
import { Management } from './shared';
import { states } from '@/lib/editorial-states';
const titles: Record<string, string> = {
projects: 'Projectos',
clusters: 'Clusters',
corridors: 'Corredores',
'cluster-project': 'Projectos associados ao cluster',
'corridor-project': 'Projectos associados ao corredor',
'corridor-cluster': 'Clusters associados ao corredor',
'value-chains': 'Etapas da cadeia de valor',
'value-chain-links': 'Ligações da cadeia de valor',
'investment-gaps': 'Lacunas de investimento',
'zone-relations': 'Relações territoriais',
};
type Item = {
id: string;
subject_kind: string;
subject_key: string;
revision_no: number;
state: string;
title: string;
};
export default function Subjects({
items,
}: {
items: {
data: Item[];
prev_page_url: string | null;
next_page_url: string | null;
};
}) {
return (
<Management title="Revisão de estruturas e relações">
<Link className="underline" href="/management/editorial">
Circuito editorial
</Link>
<p>
Prepare no formulário existente. Reveja e publique a edição
aprovada, conservando o registo anterior durante a preparação.
</p>
{!items.data.length && <p>Não existem propostas neste percurso.</p>}
<ul className="space-y-4">
{items.data.map((item) => (
<li key={item.id} className="rounded border p-4">
<p className="text-sm">
{titles[item.subject_kind] ?? item.subject_kind} ·{' '}
{states[item.state] ?? item.state} · edição{' '}
{item.revision_no}
</p>
<Link
className="font-semibold underline"
href={`/management/editorial/subjects/${item.subject_kind}/${item.subject_key}`}
>
{item.title}
</Link>
</li>
))}
</ul>
<nav aria-label="Páginas de propostas" className="flex gap-4">
{items.prev_page_url && (
<Link className="underline" href={items.prev_page_url}>
Anterior
</Link>
)}
{items.next_page_url && (
<Link className="underline" href={items.next_page_url}>
Seguinte
</Link>
)}
</nav>
</Management>
);
}
@@ -0,0 +1,316 @@
import { Link, router } from '@inertiajs/react';
import { Field, Management, button, control } from './shared';
import { states } from '@/lib/editorial-states';
type Item = {
id: number;
title: string;
revision_no: number;
state: string;
responsible: string | null;
published: boolean;
legal_required: boolean;
};
const eventTitles: Record<string, string> = {
submitted: 'Submissão',
pending_review: 'Submissão',
review_started: 'Início da revisão',
in_review: 'Início da revisão',
returned: 'Devolução',
rejected: 'Rejeição',
approved: 'Aprovação',
published: 'Publicação',
archived: 'Arquivo',
withdrawn: 'Retirada da publicação',
publication_withdrawn: 'Retirada da publicação',
suspended: 'Suspensão da publicação',
submit: 'Submissão',
start_review: 'Início da revisão',
return: 'Devolução',
reject: 'Rejeição',
legal_validate: 'Validação jurídica',
approve: 'Aprovação',
publish: 'Publicação',
archive: 'Arquivo',
};
export default function EditorialWorkflow({
items,
notices,
filters = {},
resourceAccess,
opportunitiesUrl,
opportunityPending,
clustersUrl,
portfolioUrl,
portfolioPending = 0,
demographyUrl,
demographyPending = 0,
territorialUrl,
nativePending = { clusters: 0, territories: 0 },
subjectsUrl,
subjectsPending = 0,
zonesUrl,
}: {
resourceAccess: boolean;
opportunitiesUrl: string | null;
opportunityPending: number;
clustersUrl?: string | null;
portfolioUrl?: string | null;
portfolioPending?: number;
demographyUrl?: string | null;
demographyPending?: number;
territorialUrl?: string | null;
nativePending?: { clusters: number; territories: number; zones?: number };
zonesUrl?: string | null;
subjectsUrl?: string | null;
subjectsPending?: number;
filters?: { state?: string; kind?: string };
items: { data: Item[]; current_page: number; last_page: number };
notices: {
id: number;
action: string;
read_at: string | null;
href: string;
reference: string;
}[];
}) {
const pageUrl = (page: number) =>
`/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`;
const areas = [
{
title: 'Conteúdos e oportunidades',
entries: [
{
label: 'Artigos',
href: resourceAccess
? '/management/editorial?kind=article'
: null,
},
{
label: 'Legislação',
href: resourceAccess
? '/management/editorial?kind=legislation'
: null,
},
{
label: 'Documentos',
href: resourceAccess
? '/management/editorial?kind=document'
: null,
},
{
label: 'Oportunidades',
href: opportunitiesUrl,
pending: opportunityPending,
},
{
label: 'Biblioteca e editores',
href: resourceAccess ? '/management/portal-services' : null,
},
],
},
{
title: 'Investimentos e competitividade',
entries: [
{
label: 'Carteira',
href: portfolioUrl,
pending: portfolioPending,
},
{
label: 'Competitividade dos clusters',
href: clustersUrl,
pending: nativePending.clusters,
},
{
label: 'Estruturas e relações',
href: subjectsUrl,
pending: subjectsPending,
},
],
},
{
title: 'Território e dados',
entries: [
{
label: 'Conteúdos territoriais',
href: territorialUrl,
pending: nativePending.territories,
},
{
label: 'Zonas e indicadores',
href: zonesUrl,
pending: nativePending.zones ?? 0,
},
{
label: 'Demografia',
href: demographyUrl,
pending: demographyPending,
},
],
},
].filter((area) => area.entries.some((item) => item.href));
return (
<Management title="Circuito editorial">
<p className="max-w-3xl text-sm text-slate-600">
Acompanhe a preparação, revisão e publicação numa única área de
trabalho. Seleccione uma actividade para continuar nas tarefas
da sua equipa.
</p>
<section
aria-label="Actividades do dashboard"
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
{areas.map((area) => (
<section
key={area.title}
className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"
>
<h2 className="border-b border-slate-100 bg-slate-50 px-5 py-4 text-sm font-semibold text-slate-900">
{area.title}
</h2>
<ul className="divide-y divide-slate-100">
{area.entries.map((item) =>
item.href ? (
<li key={item.label}>
<Link
href={item.href}
className="flex min-h-14 items-center justify-between gap-3 px-5 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-red-700"
>
<span>{item.label}</span>
{'pending' in item && (
<span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600">
{item.pending} pendentes
</span>
)}
</Link>
</li>
) : null,
)}
</ul>
</section>
))}
</section>
{resourceAccess && (
<>
<h2 className="text-lg font-semibold text-slate-900">
Fila de conteúdos
</h2>
<form
action="/management/editorial"
className="my-4 flex flex-wrap items-end gap-3"
>
<Field label="Conteúdo">
<select
className={control}
name="kind"
defaultValue={filters.kind ?? ''}
>
<option value="">Todos</option>
<option value="article">Artigos</option>
<option value="legislation">Legislação</option>
<option value="document">Documentos</option>
</select>
</Field>
<Field label="Estado da edição">
<select
className={control}
name="state"
defaultValue={filters.state ?? ''}
>
<option value="">Todos</option>
{Object.entries(states).map(
([state, label]) => (
<option key={state} value={state}>
{label}
</option>
),
)}
</select>
</Field>
<button className={button}>Filtrar fila</button>
</form>
<section aria-label="Fila editorial" className="space-y-3">
{items.data.map((item) => (
<article
key={item.id}
className="rounded border p-4"
>
<Link
className="font-semibold underline"
href={`/management/editorial/resources/${item.id}`}
>
{item.title}
</Link>
<p>
Edição {item.revision_no} ·{' '}
{states[item.state] ?? item.state} ·
Responsável:{' '}
{item.responsible ?? 'Por atribuir'}
</p>
<p>
{item.published
? 'Versão pública disponível'
: 'Sem versão pública'}
{item.legal_required &&
' · Exige validação jurídica'}
</p>
</article>
))}
</section>
<nav aria-label="Paginação" className="flex gap-4">
{items.current_page > 1 && (
<Link href={pageUrl(items.current_page - 1)}>
Anterior
</Link>
)}
<span>
Página {items.current_page} de {items.last_page}
</span>
{items.current_page < items.last_page && (
<Link href={pageUrl(items.current_page + 1)}>
Seguinte
</Link>
)}
</nav>
</>
)}
<section
id="notificacoes"
aria-label="Notificações editoriais"
className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-3 text-lg font-semibold">Notificações</h2>
{notices.length === 0 && (
<p className="text-sm text-slate-500">
Não há notificações neste âmbito.
</p>
)}
{notices.map((n) => (
<p
key={n.id}
className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 py-3 text-sm"
>
<Link className="underline" href={n.href}>
{eventTitles[n.action] ?? 'Alteração editorial'} ·{' '}
{n.reference}
</Link>
{!n.read_at && (
<button
className="ml-3 underline"
onClick={() =>
router.post(
`/management/editorial/notices/${n.id}/read`,
)
}
>
Marcar como lida
</button>
)}
</p>
))}
</section>
</Management>
);
}
@@ -0,0 +1,504 @@
import { useEffect, useState } from 'react';
import { Link } from '@inertiajs/react';
import { portalRequest, PortalApiError } from '@/lib/portal-api';
import { Field, Management, button, control } from './shared';
type Row = {
id: string;
name: string;
code: string;
status: string;
version_no: number;
private_revision?: boolean;
public_version_no?: number | null;
};
type State = {
location: {
id: string;
name: string;
code: string;
location_type: string;
parent_id: string | null;
version_no: number;
};
profile: {
source_id: string;
valid_from: string;
valid_to: string | null;
status: string;
editor_id: string;
reviewer_id: string | null;
};
fresh: boolean;
baseline_changed: boolean;
public_version_no: number | null;
_editorial?: { private: boolean; contributors?: string[] };
};
type Preview = {
working: State;
public: State;
history: {
id: string;
version_no: number;
reason: string;
snapshot: State;
}[];
};
const titles: Record<string, string> = {
draft: 'Rascunho',
pending_review: 'Submetido',
in_review: 'Em revisão',
approved: 'Aprovado',
published: 'Publicado',
rejected: 'Devolvido com fundamento',
archived: 'Arquivado',
};
export default function TerritorialEditorial({
roles,
actorId,
initialTerritory,
noticeVersion,
}: {
roles: string[];
actorId: string;
initialTerritory?: string | null;
noticeVersion?: number | null;
}) {
const [rows, setRows] = useState<Row[]>([]);
const [next, setNext] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [form, setForm] = useState({
name: '',
code: '',
source_id: '',
valid_from: '',
valid_to: '',
reason: '',
confirm_baseline_change: false,
});
const [sources, setSources] = useState<
{ id: string; name: string; source_version: string }[]
>([]);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string[]>>({});
const fail = (error: unknown) => {
setErrors(error instanceof PortalApiError ? error.errors : {});
setMessage(
error instanceof PortalApiError && error.status === 409
? 'A versão, fonte ou autoridade mudou. Consulte o estado guardado antes de continuar.'
: error instanceof PortalApiError && error.status === 403
? 'Confirme a sua função e renove o MFA na sessão habitual. Os dados guardados mantêm-se.'
: error instanceof Error
? error.message
: 'Operação não confirmada. Consulte o estado guardado antes de repetir.',
);
};
const list = async (
url = '/api/v1/geo/drafts?limit=30',
append = false,
) => {
const data = await portalRequest<{
data: Row[];
next_page_url: string | null;
}>(url);
setRows((current) => (append ? [...current, ...data.data] : data.data));
setNext(data.next_page_url);
};
useEffect(() => {
void Promise.all([
list(),
portalRequest<{ data: typeof sources }>(
'/api/v1/geo/sources?limit=100',
).then((data) => setSources(data.data)),
]).catch(fail);
if (initialTerritory) void open(initialTerritory);
}, []);
const open = async (id: string) => {
setBusy(true);
setMessage('');
setErrors({});
try {
const data = await portalRequest<Preview>(
`/api/v1/geo/drafts/${id}`,
);
setPreview(data);
const { location, profile } = data.working;
setForm({
name: location.name,
code: location.code,
source_id: profile.source_id,
valid_from: profile.valid_from,
valid_to: profile.valid_to ?? '',
reason: '',
confirm_baseline_change: false,
});
} catch (error) {
fail(error);
} finally {
setBusy(false);
}
};
const write = async (action?: string) => {
if (!preview) return;
setBusy(true);
setErrors({});
setMessage('');
const { location } = preview.working;
try {
await portalRequest(
`/api/v1/geo/territories/${location.id}${action ? '/transitions' : ''}`,
{
method: action ? 'POST' : 'PATCH',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify(
action
? {
action,
version_no: location.version_no,
reason: form.reason,
}
: {
...form,
valid_to: form.valid_to || null,
location_type: location.location_type,
parent_id: location.parent_id,
version_no: location.version_no,
},
),
},
);
await open(location.id);
await list();
setMessage(
action === 'published'
? 'Versão publicada.'
: action === 'withdrawn'
? 'Publicação retirada; proposta privada preservada.'
: 'Operação guardada.',
);
} catch (error) {
fail(error);
} finally {
setBusy(false);
}
};
const state = preview?.working;
const changed = Boolean(
state &&
(form.name !== state.location.name ||
form.code !== state.location.code ||
form.source_id !== state.profile.source_id ||
form.valid_from !== state.profile.valid_from ||
form.valid_to !== (state.profile.valid_to ?? '')),
);
const contributor = state?._editorial?.contributors?.includes(actorId);
const canReview = roles.includes('reviewers') && !contributor;
const canPublish =
roles.includes('publishers') &&
!contributor &&
state?.profile.reviewer_id !== actorId;
const actionButton = (action: string, title: string, allowed: boolean) =>
allowed && (
<button
key={action}
type="button"
className={button}
disabled={
busy ||
form.reason.trim().length < 10 ||
Boolean(action && changed)
}
onClick={() => void write(action)}
>
{title}
</button>
);
return (
<Management title="Revisão dos conteúdos territoriais">
<Link href="/management/editorial" className="underline">
Circuito editorial
</Link>
<p>
Edite e reveja uma proposta privada. A versão pública mantém-se
até à publicação expressa.
</p>
{message && <p role="status">{message}</p>}
{noticeVersion && (
<p>
Este aviso refere-se à versão {noticeVersion}. Confira o
histórico e a versão actual antes de decidir.
</p>
)}
<ul className="space-y-2">
{rows.map((row) => (
<li key={row.id}>
<button
className="text-left underline"
disabled={busy || changed}
onClick={() => void open(row.id)}
>
{row.name} · {titles[row.status] ?? row.status} ·
versão {row.version_no}
{row.private_revision ? ' · proposta privada' : ''}
</button>
</li>
))}
</ul>
{next && (
<button
className={button}
disabled={busy}
onClick={() => {
setBusy(true);
void list(next, true)
.catch(fail)
.finally(() => setBusy(false));
}}
>
Ver seguintes
</button>
)}
{state && preview && (
<section
className="space-y-4 rounded-lg border p-4"
aria-label="Proposta territorial"
>
<h2 className="text-xl font-semibold">
{state.location.name} —{' '}
{titles[state.profile.status] ?? state.profile.status}
</h2>
<p>
Versão pública:{' '}
{state.public_version_no ?? 'sem publicação'} ·
proposta: {state.location.version_no}. Publicação
actual: {preview.public.location.name} (
{titles[preview.public.profile.status]}).
</p>
{!state.fresh && (
<p role="alert">
O contexto foi alterado. Compare a publicação actual
e reveja os dados antes de guardar e pedir nova
aprovação.
</p>
)}
<fieldset
disabled={busy || !roles.includes('editors')}
className="grid gap-4 sm:grid-cols-2"
>
<Field
label="Designação"
error={errors.name?.join(' ')}
>
<input
className={control}
value={form.name}
onChange={(event) =>
setForm({
...form,
name: event.target.value,
})
}
/>
</Field>
<Field label="Código" error={errors.code?.join(' ')}>
<input
className={control}
value={form.code}
onChange={(event) =>
setForm({
...form,
code: event.target.value,
})
}
/>
</Field>
<Field
label="Fonte e versão"
error={errors.source_id?.join(' ')}
>
<select
className={control}
value={form.source_id}
onChange={(event) =>
setForm({
...form,
source_id: event.target.value,
})
}
>
{!sources.some(
(source) => source.id === form.source_id,
) && (
<option value={form.source_id}>
Fonte actual — {form.source_id}
</option>
)}
{sources.map((source) => (
<option key={source.id} value={source.id}>
{source.name} · {source.source_version}
</option>
))}
</select>
</Field>
<Field
label="Aplicável desde"
error={errors.valid_from?.join(' ')}
>
<input
type="date"
className={control}
value={form.valid_from}
onChange={(event) =>
setForm({
...form,
valid_from: event.target.value,
})
}
/>
</Field>
<Field
label="Aplicável até (opcional)"
error={errors.valid_to?.join(' ')}
>
<input
type="date"
className={control}
value={form.valid_to}
onChange={(event) =>
setForm({
...form,
valid_to: event.target.value,
})
}
/>
</Field>
</fieldset>
{sources.length === 100 && (
<p>
São apresentadas as primeiras 100 fontes do catálogo
existente.
</p>
)}
{state.baseline_changed && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.confirm_baseline_change}
onChange={(event) =>
setForm({
...form,
confirm_baseline_change:
event.target.checked,
})
}
/>
Conferi a publicação alterada e reconciliei esta
proposta.
</label>
)}
<Field
label="Fundamento da operação (pelo menos 10 caracteres)"
error={errors.reason?.join(' ')}
>
<textarea
className={control}
value={form.reason}
onChange={(event) =>
setForm({ ...form, reason: event.target.value })
}
/>
</Field>
{changed && (
<p>
Guarde as alterações antes de efectuar uma decisão
sobre a versão.
</p>
)}
<div className="flex flex-wrap gap-3">
{actionButton(
'',
'Guardar proposta privada',
roles.includes('editors'),
)}
{actionButton(
'pending_review',
'Submeter à revisão',
state._editorial?.private === true &&
['draft', 'rejected'].includes(
state.profile.status,
) &&
state.profile.editor_id === actorId &&
roles.includes('editors') &&
state.fresh,
)}
{actionButton(
'in_review',
'Iniciar revisão',
state.profile.status === 'pending_review' &&
canReview,
)}
{actionButton(
'approved',
'Aprovar esta versão',
state.profile.status === 'in_review' &&
canReview &&
state.fresh,
)}
{actionButton(
'rejected',
'Devolver com fundamento',
['pending_review', 'in_review'].includes(
state.profile.status,
) && canReview,
)}
{actionButton(
'published',
'Publicar versão aprovada',
state.profile.status === 'approved' &&
canPublish &&
state.fresh,
)}
{actionButton(
'archived',
state._editorial?.private
? 'Arquivar proposta privada'
: 'Arquivar publicação',
roles.includes('publishers') &&
(state._editorial?.private === true ||
preview.public.profile.status ===
'published'),
)}
{actionButton(
'withdrawn',
'Retirar publicação actual',
roles.includes('publishers') &&
preview.public.profile.status === 'published' &&
state._editorial?.private === true,
)}
</div>
<details>
<summary className="cursor-pointer underline">
Histórico (últimas 100 operações)
</summary>
<ol>
{preview.history.map((item) => (
<li key={item.id}>
Versão {item.version_no} ·{' '}
{item.snapshot.location.name} ·{' '}
{titles[item.snapshot.profile.status] ??
item.snapshot.profile.status}
: {item.reason}
</li>
))}
</ol>
</details>
</section>
)}
</Management>
);
}
+18 -2
View File
@@ -15,6 +15,8 @@ export default function ArticleEditor({
source,
images,
revisions,
workflow,
workflowEnabled = false,
}: {
resource: {
id: number;
@@ -36,6 +38,8 @@ export default function ArticleEditor({
change_type: string;
created_at: string;
}[];
workflowEnabled?: boolean;
workflow?: { id: string; state: string; revision_no: number } | null;
}) {
const initial = () => ({
body: content?.body ?? '',
@@ -106,9 +110,21 @@ export default function ArticleEditor({
<Management title={`Editar artigo — ${resource.title}`}>
<p>
O original, a origem e as versões permanecem na biblioteca.
Guardar cria uma revisão editorial e retira a publicação até à
sua acção expressa no acervo.
{workflowEnabled
? 'Guardar prepara uma edição privada e conserva a versão pública até à publicação explícita da nova edição aprovada.'
: 'No editor anterior, guardar retira a publicação até à sua acção expressa no acervo.'}
</p>
{workflow && (
<p>
<Link
className="underline"
href={`/management/editorial/resources/${resource.id}`}
>
Conferir edição {workflow.revision_no}, revisão e
publicação
</Link>
</p>
)}
<p role="status">
Revisão guardada {resource.metadata_version} ·{' '}
{resource.published ? 'Publicado' : 'Rascunho privado'}
+56 -40
View File
@@ -166,7 +166,9 @@ export default function Manage({
editorialScopeOnly = false,
managementFilters = {},
associationOptions = {},
editorialWorkflowEnabled = false,
}: {
editorialWorkflowEnabled?: boolean;
managementFilters?: { q?: string; state?: string; intent?: string };
associationOptions?: Record<
string,
@@ -984,20 +986,24 @@ export default function Manage({
/>
Destacar como essencial na página inicial
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.independent_review}
onChange={(e) =>
form.setData(
'independent_review',
e.target.checked,
)
}
/>
Foi realizada revisão independente por outro
gestor
</label>
{!editorialWorkflowEnabled && (
<label className="flex gap-2">
<input
type="checkbox"
checked={
form.data.independent_review
}
onChange={(e) =>
form.setData(
'independent_review',
e.target.checked,
)
}
/>
Foi realizada revisão independente por
outro gestor
</label>
)}
<fieldset className="space-y-3 rounded border p-4">
<legend className="font-semibold">
Relações com etapas, módulos e
@@ -1401,7 +1407,7 @@ export default function Manage({
</div>
</form>
{disclosureAuthorization && (
{disclosureAuthorization && !editorialWorkflowEnabled && (
<section
className="space-y-3 rounded border border-emerald-700 bg-emerald-50 p-5"
aria-labelledby="disclosure-authorization-title"
@@ -1684,31 +1690,41 @@ export default function Manage({
>
Ler / pré-visualizar artigo
</a>
<button
type="button"
className={button}
onClick={() => {
setActionError('');
router.post(
`/management/portal-services/resources/${r.id}`,
{ published: !r.published },
{
preserveScroll: true,
onError: (errors) =>
setActionError(
Object.values(
errors,
)[0] ??
'A transição foi recusada; confirme o estado do ficheiro, fonte e direitos.',
),
},
);
}}
>
{r.published
? 'Retirar da publicação'
: 'Publicar'}
</button>
{editorialWorkflowEnabled && (
<a
className={button}
href={`/management/editorial/resources/${r.id}`}
>
Revisão, aprovação e publicação
</a>
)}
{(!editorialWorkflowEnabled || r.published) && (
<button
type="button"
className={button}
onClick={() => {
setActionError('');
router.post(
`/management/portal-services/resources/${r.id}`,
{ published: !r.published },
{
preserveScroll: true,
onError: (errors) =>
setActionError(
Object.values(
errors,
)[0] ??
'A transição foi recusada; confirme o estado do ficheiro, fonte e direitos.',
),
},
);
}}
>
{r.published
? 'Retirar da publicação'
: 'Publicar'}
</button>
)}
</div>
{actionError && <p role="alert">{actionError}</p>}
</article>
+48 -4
View File
@@ -23,6 +23,8 @@ type Result = {
description: string | null;
url: string;
category: string;
location?: string | null;
coverage?: string | null;
};
type Results = {
data: Result[];
@@ -33,9 +35,26 @@ type Results = {
unavailable: string[];
};
type SearchAnswer = {
type: 'count' | 'guide' | 'products' | 'list';
label: string;
count: number | null;
filter: string;
category?: 'all' | 'physical' | 'financial';
};
const ANSWER_HELP: Record<SearchAnswer['type'], string> = {
count: 'Contagem dos registos publicados e disponíveis para consulta no InvesteMoz.',
list: 'Registos publicados e disponíveis para consulta no InvesteMoz.',
guide: 'Consulte o roteiro público e prepare depois o licenciamento no seu dossier. Os requisitos dependem da actividade e localização.',
products: 'Produtos físicos e referências de cadeias de valor separados dos produtos financeiros. A presença física de uma instituição não determina a cobertura do financiamento.',
};
type CategoryMeta = { icon: LucideIcon; iconClass: string; badgeClass: string };
const CATEGORY_META: Record<string, CategoryMeta> = {
'Produtos e localizações': { icon: Factory, iconClass: 'bg-teal-50 text-teal-700', badgeClass: 'bg-teal-50 text-teal-700' },
'Produtos financeiros': { icon: Landmark, iconClass: 'bg-blue-50 text-blue-700', badgeClass: 'bg-blue-50 text-blue-700' },
'Páginas e serviços': {
icon: Compass,
iconClass: 'bg-slate-100 text-slate-700',
@@ -292,9 +311,11 @@ function QuickLinks() {
export default function Search({
query,
results,
answer = null,
}: {
query: string;
results: Results;
answer?: SearchAnswer | null;
}) {
const [input, setInput] = useState(query);
useEffect(() => setInput(query), [query]);
@@ -308,7 +329,7 @@ export default function Search({
unavailable,
} = results;
const pageUrl = (page: number) =>
`/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
`/pesquisa?${new URLSearchParams({ q: query, page: String(page), ...(answer?.category ? { category: answer.category } : {}) })}`;
const start = total ? (current - 1) * perPage + 1 : 0;
const end = Math.min(start + data.length - 1, total);
const highlight = useMemo(() => buildHighlightRegex(query), [query]);
@@ -340,6 +361,7 @@ export default function Search({
role="search"
className="mt-6 flex max-w-3xl flex-col gap-2 sm:flex-row"
>
{answer?.category && <input type="hidden" name="category" value={answer.category} />}
<label htmlFor="search-query" className="sr-only">
Pesquisar no InvesteMoz
</label>
@@ -376,6 +398,26 @@ export default function Search({
</section>
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
{answer && (
<section aria-label="Resposta à pesquisa" className="mb-6 rounded-xl border border-slate-200 bg-white p-5">
<p className="text-xl font-semibold text-[#101820]" aria-live="polite">
{answer.type !== 'count' ? answer.label : answer.count === null ? 'Contagem indisponível de momento' : `${answer.count.toLocaleString('pt-MZ')} ${answer.label}`}
</p>
<p className="mt-2 text-sm text-slate-600">{ANSWER_HELP[answer.type]}</p>
{answer.filter && answer.type !== 'guide' && <p className="mt-1 text-sm text-slate-600">Correspondência textual: {answer.filter}.</p>}
{answer.type === 'products' && (
<nav aria-label="Categoria de produtos" className="mt-4 flex flex-wrap gap-2">
{([['all', 'Todos'], ['physical', 'Produtos físicos'], ['financial', 'Produtos financeiros']] as const).map(([category, label]) => (
<Link key={category} href={`/pesquisa?${new URLSearchParams({ q: answer.filter, category })}`}
aria-current={(answer.category ?? 'all') === category ? 'page' : undefined}
className={`rounded-full border px-3 py-2 text-sm font-semibold ${(answer.category ?? 'all') === category ? 'border-slate-800 bg-slate-800 text-white' : 'border-slate-200 text-slate-700 hover:bg-slate-100'}`}>
{label}
</Link>
))}
</nav>
)}
</section>
)}
{unavailable.length > 0 && (
<p
role="alert"
@@ -387,7 +429,7 @@ export default function Search({
</p>
)}
{query ? (
{query || answer ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<p
@@ -395,8 +437,8 @@ export default function Search({
className="text-sm text-slate-600"
>
{total > 0
? `Resultados ${start}–${end} de ${total} para “${query}”.`
: `Nenhum resultado nas fontes consultadas para “${query}”.`}
? `Resultados ${start}–${end} de ${total} para “${query || answer?.label}”.`
: `Nenhum resultado nas fontes consultadas para “${query || answer?.label}”.`}
</p>
{last > 1 && (
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
@@ -473,6 +515,8 @@ export default function Search({
/>
</p>
)}
{item.location && <p className="mt-2 text-sm text-slate-700"><MapPin size={14} className="mr-1 inline" aria-hidden="true" />{item.location}</p>}
{item.coverage && <p className="mt-1 text-xs text-slate-600">{item.coverage}</p>}
</div>
<ArrowUpRight
size={20}
File diff suppressed because it is too large Load Diff
+51 -35
View File
@@ -29,6 +29,7 @@ export default function Show(props: Props) {
}
type Props = {
editorialEnabled?: boolean;
competitiveness?: ApprovedCompetitiveness | null;
complements?: ComplementOption[];
geography: StructureGeography;
@@ -63,6 +64,7 @@ type Props = {
};
function Editor({
editorialEnabled = false,
competitiveness,
complements = [],
record,
@@ -423,43 +425,57 @@ function Editor({
Guardar rascunho
</button>
</form>
<form
className="space-y-3 rounded border p-4"
onSubmit={(e) => {
e.preventDefault();
approval.post(`${base}/${record.id}/publish`);
}}
>
<p>
O gestor autorizado pode aprovar e publicar esta
ficha. Registe a referência da sua decisão.
{editorialEnabled ? (
<p className="rounded border p-4">
A edição privada preserva a ficha pública anterior.{' '}
<Link
className="underline"
href={`/management/editorial/portfolio/${record.id}`}
>
Conferir versões, submeter, aprovar e publicar
no circuito editorial
</Link>
.
</p>
<Field label="Referência da autorização e revisão">
<textarea
className={control}
required
minLength={15}
value={approval.data.approval_evidence}
onChange={(e) =>
approval.setData(
'approval_evidence',
e.target.value,
)
}
/>
</Field>
{approval.errors.approval_evidence && (
<p role="alert">
{approval.errors.approval_evidence}
</p>
)}
<button
className={button}
disabled={approval.processing || form.isDirty}
) : (
<form
className="space-y-3 rounded border p-4"
onSubmit={(e) => {
e.preventDefault();
approval.post(`${base}/${record.id}/publish`);
}}
>
Validar e publicar
</button>
</form>
<p>
O gestor autorizado pode aprovar e publicar esta
ficha. Registe a referência da sua decisão.
</p>
<Field label="Referência da autorização e revisão">
<textarea
className={control}
required
minLength={15}
value={approval.data.approval_evidence}
onChange={(e) =>
approval.setData(
'approval_evidence',
e.target.value,
)
}
/>
</Field>
{approval.errors.approval_evidence && (
<p role="alert">
{approval.errors.approval_evidence}
</p>
)}
<button
className={button}
disabled={approval.processing || form.isDirty}
>
Validar e publicar
</button>
</form>
)}
</>
) : (
<>
+177 -36
View File
@@ -14,6 +14,7 @@ import type {
Force,
} from '@/lib/cluster-competitiveness';
import { button, control } from '../management/shared';
import { states as editorialStates } from '@/lib/editorial-states';
const labels = {
evidence: 'Observações e evidências locais',
@@ -33,22 +34,67 @@ export type CompetitivenessState = {
at: string;
} | null;
history: { version: number; action: string; at: string; actor: number }[];
editorial_enabled?: boolean;
workflow?: {
state: string;
edition: number;
hash: string;
fresh: boolean;
contributors: number[];
independent_required: boolean;
};
published?: CompetitivenessState['approved'];
};
const actions: Record<string, string> = {
saved: 'Gravação',
approved: 'Aprovação pública',
approved: 'Aprovação',
withdrawn: 'Retirada da aprovação',
restored: 'Recuperação do histórico',
submitted: 'Submissão',
review_started: 'Início da revisão',
returned: 'Devolução',
rejected: 'Rejeição',
published: 'Publicação',
archived: 'Arquivo',
};
const editorialActions: Record<string, [string, string][]> = {
draft: [
['submitted', 'Submeter para revisão'],
['archived', 'Arquivar rascunho'],
],
returned: [
['submitted', 'Voltar a submeter'],
['archived', 'Arquivar rascunho'],
],
submitted: [
['review_started', 'Iniciar revisão'],
['archived', 'Arquivar edição'],
],
in_review: [
['returned', 'Devolver para correcção'],
['rejected', 'Rejeitar com fundamento'],
['approved', 'Aprovar edição'],
['archived', 'Arquivar edição'],
],
approved: [
['published', 'Publicar edição aprovada'],
['archived', 'Arquivar edição'],
],
rejected: [['archived', 'Arquivar edição']],
published: [['archived', 'Arquivar e retirar publicação']],
archived: [],
};
export default function Competitiveness({
clusterId,
sector,
initial,
viewerId,
}: {
clusterId: string;
sector: string;
initial?: CompetitivenessState | null;
viewerId?: number;
}) {
const [draft, setDraft] = useState(
() => initial?.draft ?? newCompetitivenessDraft(clusterId, sector),
@@ -69,6 +115,8 @@ export default function Competitiveness({
server.draft ?? newCompetitivenessDraft(clusterId, sector),
);
const endpoint = `/management/clusters/${clusterId}/competitiveness`;
const editorial = server.editorial_enabled || !!server.workflow;
const publicVersion = server.workflow ? server.published : server.approved;
function errorMessage(error: unknown) {
if (error instanceof PortalApiError) {
if (error.status === 409)
@@ -164,11 +212,36 @@ export default function Competitiveness({
{dirty
? 'Existem alterações por guardar.'
: 'Campos correspondem à versão recuperada.'}{' '}
{server.approved
? `Consulta pública: versão aprovada ${server.approved.version}; novas edições conservam essa versão até outra aprovação.`
{publicVersion
? `Consulta pública: versão ${publicVersion.version}; novas edições conservam essa versão até outra publicação ou retirada explícita.`
: 'Sem análise aprovada para consulta pública.'}
</p>
<fieldset disabled={busy} className="space-y-4">
{editorial && (
<p>
Aprovar e publicar são operações distintas. Estado da
edição: {editorialStates[server.workflow?.state ?? 'draft']}
. A publicação anterior mantém-se durante a revisão.
</p>
)}
{server.workflow && !server.workflow.fresh && (
<p role="alert">
O contexto do cluster mudou. Guarde uma nova edição e volte
a conferir antes de aprovar ou publicar.
</p>
)}
{server.workflow && !server.editorial_enabled && (
<p role="alert">
O circuito está desactivado neste ambiente. A publicação e o
histórico foram preservados; a edição está temporariamente
só para consulta.
</p>
)}
<fieldset
disabled={
busy || (!!server.workflow && !server.editorial_enabled)
}
className="space-y-4"
>
<label className="block">
Sector analisado
<input
@@ -333,7 +406,10 @@ export default function Competitiveness({
<button
type="button"
className={button}
disabled={!dirty}
disabled={
!dirty &&
!(server.editorial_enabled && !server.workflow)
}
onClick={() => command('saved', { draft })}
>
Guardar rascunho no servidor
@@ -446,13 +522,23 @@ export default function Competitiveness({
)}
</details>
<details className="rounded border p-3">
<summary>Aprovação para consulta pública</summary>
<summary>
{editorial
? 'Revisão e publicação'
: 'Aprovação para consulta pública'}
</summary>
<p>
Aprovar torna a versão guardada consultável na ficha
pública deste cluster, quando essa ficha estiver
publicada. Guardar um rascunho não o publica. A
evidência de aprovação e as identidades do histórico
ficam na gestão.
{editorial ? (
'Aprovar regista a decisão sobre esta edição. Publicar disponibiliza-a na ficha pública do cluster. As edições seguintes exigem nova aprovação.'
) : (
<>
Aprovar torna a versão guardada consultável na
ficha pública deste cluster, quando essa ficha
estiver publicada. Guardar um rascunho não o
publica. A evidência de aprovação e as
identidades do histórico ficam na gestão.
</>
)}
</p>
<label className="block">
Evidência / motivo da aprovação ou retirada
@@ -463,31 +549,86 @@ export default function Competitiveness({
onChange={(e) => setReason(e.target.value)}
/>
</label>
<button
type="button"
className={button}
disabled={
dirty ||
!diagnosticReady(draft) ||
server.version === 0 ||
reason.trim().length < 10
}
onClick={() => command('approved', { reason })}
>
Aprovar versão guardada para consulta pública
</button>{' '}
<button
type="button"
className={button}
disabled={
dirty ||
!server.approved ||
reason.trim().length < 10
}
onClick={() => command('withdrawn', { reason })}
>
Retirar aprovação pública
</button>
{editorial ? (
<div className="flex flex-wrap gap-2">
{(
editorialActions[
server.workflow?.state ?? 'draft'
] ?? []
).map(([action, label]) => (
<button
key={action}
type="button"
className={button}
disabled={
dirty ||
reason.trim().length < 10 ||
server.version === 0 ||
!server.workflow ||
([
'submitted',
'approved',
'published',
].includes(action) &&
(!diagnosticReady(draft) ||
server.workflow?.fresh ===
false)) ||
(action === 'approved' &&
!!server.workflow
?.independent_required &&
server.workflow.contributors.includes(
viewerId ?? -1,
))
}
onClick={() => command(action, { reason })}
>
{label}
</button>
))}
{publicVersion && (
<button
type="button"
className={button}
disabled={
dirty || reason.trim().length < 10
}
onClick={() =>
command('withdrawn', { reason })
}
>
Retirar publicação actual
</button>
)}
</div>
) : (
<>
<button
type="button"
className={button}
disabled={
dirty ||
!diagnosticReady(draft) ||
server.version === 0 ||
reason.trim().length < 10
}
onClick={() => command('approved', { reason })}
>
Aprovar versão guardada para consulta pública
</button>{' '}
<button
type="button"
className={button}
disabled={
dirty ||
!server.approved ||
reason.trim().length < 10
}
onClick={() => command('withdrawn', { reason })}
>
Retirar aprovação pública
</button>
</>
)}
{dirty && (
<p>
Guarde primeiro as alterações ou recupere a versão
+110 -1
View File
@@ -12,6 +12,7 @@ import type { ChainLink, Gap } from './value-network';
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
const form = useForm({
editorial_version: chain?.editorial_version ?? null,
code: chain?.code ?? '',
name: chain?.name ?? '',
stage: chain?.stage ?? 'input',
@@ -34,6 +35,11 @@ function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
<h3 className="font-semibold">
{chain ? chain.name : 'Nova etapa'}
</h3>
{chain?.editorial_url && (
<Link className="underline" href={chain.editorial_url}>
Conferir proposta e publicação
</Link>
)}
<Field label="Código da etapa" error={form.errors.code}>
<input
className={control}
@@ -78,7 +84,13 @@ export default function Editor({
chainLinks,
gaps,
competitiveness,
editorialDossier,
editorialSubject,
structuralEditorial = false,
}: {
editorialDossier?: string | null;
editorialSubject?: string | null;
structuralEditorial?: boolean;
structure: Structure;
record: StructureRecord | null;
projects: { id: string; name: string; code: string }[];
@@ -99,6 +111,8 @@ export default function Editor({
valid_until: string | null;
validation_status: string;
version_no: number;
editorial_version?: number;
editorial_url?: string;
editor_id: number | null;
}[];
}) {
@@ -111,6 +125,7 @@ export default function Editor({
? 'cluster'
: 'corredor';
const form = useForm({
editorial_version: record?.editorial_version ?? null,
code: record?.code ?? '',
name: record?.name ?? '',
status:
@@ -130,6 +145,7 @@ export default function Editor({
const association = useForm({
project_id: '',
version_no: 0,
editorial_version: 0,
role: 'complementary',
association_type: 'potential',
source_reference: '',
@@ -139,6 +155,23 @@ export default function Editor({
});
return (
<Management title={`${record ? 'Editar' : 'Criar'} ${singular}`}>
{editorialSubject && (
<p>
As alterações ficam numa proposta privada.{' '}
<Link className="underline" href={editorialSubject}>
Conferir edição e publicar
</Link>
.
</p>
)}
{structuralEditorial && (
<Link
className="underline"
href="/management/editorial/subjects"
>
Propostas de estruturas, relações e cadeias de valor
</Link>
)}
<Link className="underline" href={`/management/${structure}`}>
Voltar a {titles[structure].toLowerCase()}
</Link>
@@ -146,6 +179,19 @@ export default function Editor({
Registo interno de gestão. O estado activo não publica este
conteúdo no portal.
</p>
{editorialDossier && (
<p className="rounded border p-3">
As alterações de dados-base e localização são guardadas numa
edição privada, preservando a informação pública.{' '}
<Link
className="underline"
href={`/management/editorial/portfolio/${editorialDossier}`}
>
Conferir, submeter e publicar a edição da carteira
</Link>
.
</p>
)}
{!record && (
<TerritoryFields
endpoint="/management/territory-options"
@@ -307,6 +353,7 @@ export default function Editor({
clusterId={record.id}
sector={record.sector ?? ''}
initial={competitiveness}
viewerId={viewerId}
/>
<section className="max-w-3xl space-y-4">
<h2 className="text-xl font-semibold">
@@ -334,7 +381,16 @@ export default function Editor({
{member.evidence_reference ?? 'Sem evidencia'} -{' '}
{member.valid_from ?? 'Sem inicio'} -{' '}
{member.valid_until ?? 'Sem termo'}
{member.validation_status === 'pending' &&
{member.editorial_url && (
<Link
className="underline"
href={member.editorial_url}
>
Rever proposta e publicação
</Link>
)}
{!structuralEditorial &&
member.validation_status === 'pending' &&
member.editor_id !== viewerId && (
<button
className={button}
@@ -387,6 +443,59 @@ export default function Editor({
member.project_id ===
e.target.value,
)?.version_no ?? 0,
editorial_version:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.editorial_version ?? 0,
role:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.role ?? 'complementary',
association_type:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.association_type ??
'potential',
source_reference:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.source_reference ?? '',
evidence_reference:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.evidence_reference ?? '',
valid_from:
memberships
.find(
(member) =>
member.project_id ===
e.target.value,
)
?.valid_from?.slice(
0,
10,
) ?? '',
valid_until:
memberships
.find(
(member) =>
member.project_id ===
e.target.value,
)
?.valid_until?.slice(
0,
10,
) ?? '',
}))
}
>
+14 -1
View File
@@ -9,6 +9,7 @@ import {
import type { Paginated } from '../management/shared';
import { titles, statuses } from './types';
import type { Structure, StructureRecord } from './types';
import { states as editorialStates } from '@/lib/editorial-states';
export default function Index({
structure,
@@ -16,7 +17,7 @@ export default function Index({
filters,
}: {
structure: Structure;
records: Paginated<StructureRecord>;
records: Paginated<StructureRecord & { editorial_state?: string }>;
filters: { q?: string };
}) {
const form = useForm({ q: filters.q ?? '' });
@@ -61,6 +62,18 @@ export default function Index({
{record.code} · {statuses[structure][record.status]}
</p>
{record.sector && <p>{record.sector}</p>}
{record.editorial_state && (
<p>
Competitividade:{' '}
{editorialStates[record.editorial_state] ??
{
legacy_published:
'Publicação anterior preservada',
not_started: 'Sem análise guardada',
}[record.editorial_state] ??
record.editorial_state}
</p>
)}
</article>
))}
</div>
+9 -1
View File
@@ -10,6 +10,7 @@ export type StructureRecord = {
budget_currency?: string | null;
start_date?: string | null;
end_date?: string | null;
editorial_version?: number;
};
export const titles = {
projects: 'Projectos',
@@ -40,4 +41,11 @@ export const roles: Record<string, string> = {
complementary: 'Complementar',
supporting: 'Apoio',
};
export type Chain = { id: string; code: string; name: string; stage: string };
export type Chain = {
id: string;
code: string;
name: string;
stage: string;
editorial_version?: number;
editorial_url?: string;
};
@@ -17,6 +17,8 @@ export type Gap = {
rationale: string;
status: string;
call_to_action: string;
editorial_version?: number;
editorial_url?: string;
};
const linkTypes: Record<string, string> = {
supplies: 'Fornece a',
@@ -72,6 +74,7 @@ function GapForm({
gap?: Gap;
}) {
const form = useForm({
editorial_version: gap?.editorial_version ?? null,
value_chain_id: gap?.value_chain_id ?? '',
title: gap?.title ?? '',
location: gap?.location ?? '',
+2
View File
@@ -66,7 +66,9 @@ Route::middleware([CorrelationId::class, 'auth', 'verified', EnsureScopedPortalA
Route::get('/{id}', [InvestmentZoneController::class, 'show'])->whereUuid('id');
Route::post('/', [InvestmentZoneController::class, 'save']);
Route::put('/{id}', [InvestmentZoneController::class, 'save'])->whereUuid('id');
Route::post('/{id}/editorial/{action}', [InvestmentZoneController::class, 'editorialReview'])->whereUuid('id')->whereIn('action', ['submit', 'start_review', 'approve', 'return', 'reject', 'archive']);
Route::post('/{id}/{action}', [InvestmentZoneController::class, 'transition'])->whereUuid('id')->whereIn('action', ['publish', 'suspend', 'reconcile']);
Route::get('/{id}/revisions', [InvestmentZoneController::class, 'revisions'])->whereUuid('id');
Route::get('/{id}/revisions/{version}', [InvestmentZoneController::class, 'revision'])->whereUuid('id')->whereNumber('version');
});
});
+4
View File
@@ -6,6 +6,9 @@ use App\Http\Middleware\RequireZoneMfa;
use App\Http\Middleware\TerritorialRequest;
use Illuminate\Support\Facades\Route;
Route::get('/management/territorial/editorial', [TerritorialRegistryController::class, 'editor'])
->middleware(['auth', 'verified', 'can:manage-portal', RequireZoneMfa::class, PrivateInvestmentResponse::class, TerritorialRequest::class]);
Route::prefix('api/v1/geo')->middleware(['auth', 'verified', PrivateInvestmentResponse::class, 'throttle:api', TerritorialRequest::class])->group(function () {
Route::get('/search', [TerritorialRegistryController::class, 'index']);
Route::get('/{collection}', [TerritorialRegistryController::class, 'index'])->whereIn('collection', ['countries', 'provinces', 'districts', 'admin-posts', 'localities']);
@@ -13,6 +16,7 @@ Route::prefix('api/v1/geo')->middleware(['auth', 'verified', PrivateInvestmentRe
Route::middleware(['can:manage-portal', RequireZoneMfa::class])->group(function () {
Route::get('/sources', [TerritorialRegistryController::class, 'sources']);
Route::get('/drafts', [TerritorialRegistryController::class, 'drafts']);
Route::get('/drafts/{id}', [TerritorialRegistryController::class, 'preview'])->whereUuid('id');
Route::post('/sources', [TerritorialRegistryController::class, 'source']);
Route::post('/territories', [TerritorialRegistryController::class, 'save']);
Route::patch('/territories/{id}', [TerritorialRegistryController::class, 'save'])->whereUuid('id');
+20 -3
View File
@@ -37,6 +37,7 @@ use App\Http\Controllers\OpportunityManagementController;
use App\Http\Controllers\OpportunityOriginReviewController;
use App\Http\Controllers\OpportunityPromotionController;
use App\Http\Controllers\OpportunityResourceController;
use App\Http\Controllers\PortalEditorialWorkflowController;
use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalProfileController;
use App\Http\Controllers\PortalProfileInstallationController;
@@ -95,6 +96,22 @@ Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing']
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/management/editorial', [PortalEditorialWorkflowController::class, 'index'])->name('editorial.workflow');
Route::get('/management/editorial/subjects', [PortalEditorialWorkflowController::class, 'subjects']);
Route::get('/management/editorial/subjects/{kind}/{id}', [PortalEditorialWorkflowController::class, 'subject'])->whereUuid('id');
Route::get('/management/editorial/subjects/{kind}/{id}/choices', [PortalEditorialWorkflowController::class, 'subjectChoices'])->whereUuid('id');
Route::post('/management/editorial/subjects/{kind}/{id}/drafts', [PortalEditorialWorkflowController::class, 'subjectSave'])->whereUuid('id');
Route::post('/management/editorial/subjects/{kind}/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'subjectTransition'])->whereUuid(['id', 'edition']);
Route::get('/management/editorial/resources/{id}', [PortalEditorialWorkflowController::class, 'show'])->whereNumber('id');
Route::post('/management/editorial/resources/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'transition'])->whereNumber('id')->whereUuid('edition');
Route::get('/management/editorial/resources/{id}/editions/{edition}/files/{document}', [PortalEditorialWorkflowController::class, 'file'])->whereNumber('id')->whereUuid(['edition', 'document']);
Route::post('/management/editorial/notices/{notice}/read', [PortalEditorialWorkflowController::class, 'markRead'])->whereNumber('notice');
Route::get('/management/editorial/portfolio/{id}', [PortalEditorialWorkflowController::class, 'portfolio'])->whereUuid('id');
Route::post('/management/editorial/portfolio/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'portfolioTransition'])->whereUuid(['id', 'edition']);
Route::get('/management/editorial/demography/{id}', [PortalEditorialWorkflowController::class, 'demography'])->whereUuid('id');
Route::post('/management/editorial/demography/{id}/drafts', [PortalEditorialWorkflowController::class, 'demographyDraft'])->whereUuid('id');
Route::post('/management/editorial/demography/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'demographyTransition'])->whereUuid(['id', 'edition']);
Route::get('/management/editorial/demography/{id}/original', [PortalEditorialWorkflowController::class, 'demographyOriginal'])->whereUuid('id');
Route::get('/gestao-conteudos', fn (Request $request) => Inertia::render('management/content-hub', app(ContentManagement::class)->view($request->user())))->name('content-management');
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
@@ -299,17 +316,17 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis');
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
});
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->middleware(PrivateInvestmentResponse::class)->name('portfolio.index');
Route::get('relationship-repair', [InvestmentRelationshipRepairController::class, 'preview'])->name('relationship-repair.preview');
Route::get('opportunity-origin', [OpportunityOriginReviewController::class, 'index'])->name('opportunity-origin.index');
Route::post('opportunity-origin/preview', [OpportunityOriginReviewController::class, 'preview'])->name('opportunity-origin.preview');
Route::post('opportunity-origin/apply', [OpportunityOriginReviewController::class, 'apply'])->name('opportunity-origin.apply');
Route::post('relationship-repair', [InvestmentRelationshipRepairController::class, 'apply'])->name('relationship-repair.apply');
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->middleware(PrivateInvestmentResponse::class)->name('portfolio.show');
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
Route::get('clusters/{cluster}/competitiveness', [ClusterCompetitivenessController::class, 'show'])->name('clusters.competitiveness.show');
Route::post('clusters/{cluster}/competitiveness/{action}', [ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored'])->name('clusters.competitiveness.change');
Route::post('clusters/{cluster}/competitiveness/{action}', [ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored', 'submitted', 'review_started', 'returned', 'rejected', 'published', 'archived'])->name('clusters.competitiveness.change');
Route::post('clusters/{cluster}/chain-links', [ClusterValueChainController::class, 'link'])->name('clusters.chain-links.store');
Route::delete('clusters/{cluster}/chain-links/{link}', [ClusterValueChainController::class, 'removeLink'])->name('clusters.chain-links.delete');
Route::post('clusters/{cluster}/gaps', [ClusterValueChainController::class, 'gap'])->name('clusters.gaps.store');
@@ -0,0 +1,189 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use App\Services\ContentManagement;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class ClusterEditorialWorkflowTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $author;
private User $reviewer;
private EconomicCluster $cluster;
private array $draft;
private int $version = 0;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
[$this->author, $this->reviewer] = User::factory()->count(2)->create()->all();
config(['portal.managers' => [$this->author->email, $this->reviewer->email], 'portal.profile_access_enabled' => false,
'editorial_workflow.cluster_competitiveness' => true, 'editorial_workflow.independent_approval.kinds' => ['cluster_competitiveness']]);
$this->cluster = EconomicCluster::create(['code' => 'ED-QA-'.bin2hex(random_bytes(6)), 'name' => 'Cluster sintético QA', 'sector' => 'Energia', 'status' => 'planned']);
$this->draft = ['version' => 1, 'clusterId' => $this->cluster->id, 'sector' => 'Energia', 'region' => 'Local sintético', 'forces' => []];
foreach (ClusterCompetitiveness::FORCES as $force) {
$this->draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'Exemplo sintético', 'source' => 'Fonte sintética QA p. 2', 'period' => '2026',
'rationale' => 'Hipótese sintética', 'limitations' => 'Não são dados reais', 'opportunity' => 'Ideia de demonstração'];
}
}
private function url(): string
{
return '/management/clusters/'.$this->cluster->id.'/competitiveness';
}
private function action(User $actor, string $action, array $extra = [], int $status = 200): array
{
$this->actingAsWithMfa($actor);
$response = $this->postJson($this->url().'/'.$action, ['expected_version' => $this->version, 'reason' => 'Decisão sintética fundamentada em QA', ...$extra])->assertStatus($status);
if ($status !== 200) {
return [];
}
$this->version++;
return $response->assertJsonPath('version', $this->version)->json();
}
private function approved(): array
{
$this->action($this->author, 'saved', ['draft' => $this->draft]);
$this->action($this->author, 'submitted');
$this->action($this->reviewer, 'review_started');
return $this->action($this->reviewer, 'approved');
}
public function test_separate_approval_publication_drafts_restore_archive_and_public_projection(): void
{
$dossier = InvestmentDossier::create(['cluster_id' => $this->cluster->id, 'title' => 'Dossier QA', 'source' => 'Fonte sintética', 'content' => ['sector' => 'Energia'],
'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética em QA', 'published_at' => now()]);
$approved = $this->approved();
$service = app(ClusterCompetitiveness::class);
$this->assertNull($service->publicData($this->cluster->id));
$this->assertSame('approved', $approved['workflow']['state']);
$this->action($this->reviewer, 'published');
$public = $service->publicData($this->cluster->id);
$this->assertSame($this->draft, $public['draft']);
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (Assert $p) => $p->where('competitiveness.draft.region', 'Local sintético')->missing('competitiveness.workflow')->missing('competitiveness.actor'));
$changed = [...$this->draft, 'region' => 'Edição privada deliberada'];
$draft = $this->action($this->author, 'saved', ['draft' => $changed]);
$this->assertNull($draft['workflow']['approval']);
$this->assertSame($public, $service->publicData($this->cluster->id));
$this->action($this->author, 'published', [], 409);
$this->action($this->author, 'restored', ['revision' => 1]);
$this->action($this->author, 'archived');
$this->assertSame($public, $service->publicData($this->cluster->id));
$this->getJson($this->url())->assertJsonPath('workflow.state', 'archived')->assertJsonPath('draft', $this->draft);
$this->action($this->reviewer, 'withdrawn');
$this->assertNull($service->publicData($this->cluster->id));
$this->assertSame($this->version, DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $this->cluster->id)->count());
}
public function test_independence_contributors_return_reject_and_conflicts(): void
{
$this->action($this->author, 'saved', ['draft' => $this->draft]);
$this->action($this->author, 'submitted');
$this->action($this->reviewer, 'review_started');
$this->action($this->author, 'approved', [], 403);
$this->action($this->reviewer, 'returned');
$this->action($this->reviewer, 'saved', ['draft' => [...$this->draft, 'region' => 'Alteração do revisor']]);
$this->action($this->author, 'saved', ['draft' => $this->draft, 'expected_version' => 0], 409);
$this->action($this->author, 'submitted');
$this->action($this->reviewer, 'review_started');
$this->action($this->reviewer, 'approved', [], 403);
$this->action($this->reviewer, 'rejected');
$this->action($this->author, 'submitted', [], 409);
$this->action($this->author, 'restored', ['revision' => 1]);
$this->getJson($this->url())->assertJsonPath('workflow.state', 'draft')->assertJsonPath('workflow.approval', null);
}
public function test_changed_cluster_context_or_revoked_approval_requires_new_decision(): void
{
$this->approved();
$this->cluster->update(['sector' => 'Outro sector sintético']);
$this->getJson($this->url())->assertJsonPath('workflow.fresh', false);
$this->action($this->reviewer, 'published', [], 409);
$this->approved();
config(['portal.managers' => [$this->author->email]]);
$this->action($this->author, 'published', [], 403);
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($this->cluster->id));
}
public function test_legacy_publication_survives_activation_and_disabling_cannot_bypass_workflow(): void
{
config(['editorial_workflow.cluster_competitiveness' => false]);
$this->action($this->author, 'saved', ['draft' => $this->draft]);
$this->action($this->author, 'approved');
$service = app(ClusterCompetitiveness::class);
$public = $service->publicData($this->cluster->id);
config(['editorial_workflow.cluster_competitiveness' => true]);
$this->action($this->author, 'submitted', [], 409);
$this->action($this->author, 'saved', ['draft' => [...$this->draft, 'region' => 'Nova edição privada']]);
$this->assertSame($public, $service->publicData($this->cluster->id));
config(['editorial_workflow.cluster_competitiveness' => false]);
$this->action($this->author, 'approved', [], 503);
$this->action($this->author, 'saved', ['draft' => $this->draft], 503);
$this->assertSame($public, $service->publicData($this->cluster->id));
}
public function test_permissions_mfa_and_incomplete_evidence_do_not_write(): void
{
$this->getJson($this->url())->assertUnauthorized();
$this->actingAs(User::factory()->create());
$this->postJson($this->url().'/published', [])->assertForbidden();
$this->actingAsWithMfa($this->author);
session()->forget('portal.mfa_at');
$this->postJson($this->url().'/saved', ['expected_version' => 0, 'draft' => $this->draft])->assertForbidden();
$draft = $this->draft;
$draft['forces']['buyers']['source'] = '';
$this->action($this->author, 'saved', ['draft' => $draft]);
$this->action($this->author, 'submitted', [], 422);
$this->action($this->author, 'restored', ['revision' => 999], 404);
$this->getJson($this->url())->assertJsonPath('version', 1)->assertJsonCount(1, 'history');
}
public function test_existing_management_entry_and_cluster_list_show_editorial_state_without_new_grants(): void
{
$this->action($this->author, 'saved', ['draft' => $this->draft]);
$this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $p) => $p->where('access.clusters.index', '/management/clusters'));
$this->get('/management/clusters?q='.$this->cluster->code)->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.data.0.editorial_state', 'draft')->where('records.data.0.status', 'planned'));
$other = User::factory()->create();
$this->assertArrayNotHasKey('clusters', app(ContentManagement::class)->access($other));
config(['editorial_workflow.cluster_competitiveness' => false]);
$this->assertArrayNotHasKey('clusters', app(ContentManagement::class)->access($this->author));
}
public function test_withdrawal_and_context_changes_do_not_cancel_or_trap_a_private_review(): void
{
$this->approved();
$this->action($this->reviewer, 'published');
$this->action($this->author, 'saved', ['draft' => [...$this->draft, 'region' => 'Revisão privada']]);
$this->action($this->author, 'submitted');
$this->action($this->reviewer, 'review_started');
$withdrawn = $this->action($this->reviewer, 'withdrawn');
$this->assertSame('in_review', $withdrawn['workflow']['state']);
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($this->cluster->id));
$this->cluster->update(['name' => 'Contexto alterado durante revisão']);
$this->action($this->reviewer, 'approved', [], 409);
$returned = $this->action($this->reviewer, 'returned');
$this->assertSame('returned', $returned['workflow']['state']);
$this->assertSame('Revisão privada', $returned['draft']['region']);
}
}
@@ -0,0 +1,197 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class DemographyEditorialWorkflowTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $editor;
private User $reviewer;
private string $batch;
private string $legacy;
private string $province;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
Storage::fake('local');
[$this->editor, $this->reviewer] = User::factory()->count(2)->create()->all();
config(['portal.managers' => [$this->editor->email, $this->reviewer->email], 'portal.profile_access_enabled' => false, 'demography.enabled' => true,
'editorial_workflow.enabled' => true, 'editorial_workflow.demography' => true, 'editorial_workflow.independent_approval.kinds' => ['demography']]);
$this->actingAsWithMfa($this->editor);
$this->actingAsWithMfa($this->reviewer);
$country = (string) Str::uuid();
$this->province = (string) Str::uuid();
foreach ([[$country, null, 'country'], [$this->province, $country, 'province']] as [$id, $parent, $type]) {
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC EDITORIAL '.$type, 'code' => $id, 'location_type' => $type,
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id, 'data_classification' => 'public']);
}
$this->legacy = $this->batch('approved', '100.125');
$this->batch = $this->batch('imported', '110.250');
}
private function batch(string $status, string $value): string
{
$id = (string) Str::uuid();
$original = 'SYNTHETIC PREIMPORTED QA ONLY '.$id;
$preview = json_encode(['source' => 'SYNTHETIC QA ONLY', 'record_count' => 1]);
Storage::disk('local')->put('demography/'.$id.'/original.xlsx', $original);
Storage::disk('local')->put('demography/'.$id.'/preview.json', $preview);
$sha = hash('sha256', $original);
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'preview_sha256' => hash('sha256', $preview), 'file_name' => 'SYNTHETIC-EDITORIAL.xlsx',
'source' => 'SYNTHETIC QA ONLY', 'publication_version' => 'Exemplar sintético, não é fonte INE real', 'status' => $status, 'prepared_by' => $this->editor->id,
'report' => json_encode(['record_count' => 1, 'issue_counts' => ['unit_not_supplied' => 1], 'issues' => [], 'territories' => [], 'scan' => ['result' => 'clean', 'engine' => 'SYNTHETIC QA ONLY', 'sha256' => $sha]])]);
DB::table('demographic_observations')->insert(['import_id' => $id, 'geo_location_id' => $this->province, 'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all',
'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => $value, 'numeric_value' => $value, 'sheet' => '2026', 'cell' => 'B4', 'validation_status' => 'warning']);
return $id;
}
private function draft(int $expected = 0, int $status = 201): ?\stdClass
{
$this->actingAsWithMfa($this->editor);
$this->postJson('/management/editorial/demography/'.$this->batch.'/drafts', ['expected_revision' => $expected])->assertStatus($status);
return app(PortalEditorialWorkflow::class)->latestDemography($this->batch);
}
private function action(\stdClass $edition, string $action, User $actor, int $status = 200): \stdClass
{
$this->actingAsWithMfa($actor);
$this->postJson('/management/editorial/demography/'.$this->batch.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action,
'assigned_to' => $this->reviewer->id, 'reason' => 'Conferência sintética e publicação explícita em QA'])->assertStatus($status);
return DB::table('portal_editorial_editions')->where('id', $edition->id)->first();
}
private function approve(\stdClass $edition): \stdClass
{
$edition = $this->action($edition, 'submit', $this->editor);
$edition = $this->action($edition, 'start_review', $this->reviewer);
return $this->action($edition, 'approve', $this->reviewer);
}
public function test_approval_does_not_publish_new_series_and_legacy_values_are_preserved_through_publication_and_archive(): void
{
$legacy = DB::table('demographic_imports')->where('id', $this->legacy)->first();
$before = DB::table('demographic_observations')->whereIn('import_id', [$this->legacy, $this->batch])->orderBy('id')->get()->toJson();
$edition = $this->draft();
$this->draft(0, 409);
$edition = $this->approve($edition);
$service = app(DemographicEvidence::class);
$this->assertSame('no_data', $service->analyse([$this->province], 2026, 2026, 'general', $this->batch)['state']);
$this->assertSame('approved', $edition->state);
$this->assertSame('imported', DB::table('demographic_imports')->where('id', $this->batch)->value('status'));
$this->action($edition, 'publish', $this->reviewer);
$evidence = $service->analyse([$this->province], 2026, 2026, 'general', $this->batch);
$this->assertSame('available', $evidence['state']);
$this->assertSame('110.250', $evidence['territories'][0]['series'][0]['values'][0]['original_value']);
$this->assertSame(1, $evidence['territories'][0]['series'][0]['issue_counts']['unit_not_supplied']);
$this->assertEquals($legacy, DB::table('demographic_imports')->where('id', $this->legacy)->first());
$this->assertSame($before, DB::table('demographic_observations')->whereIn('import_id', [$this->legacy, $this->batch])->orderBy('id')->get()->toJson());
try {
(require database_path('migrations/2026_11_13_233000_extend_editorial_editions_to_demography.php'))->down();
$this->fail('Não eliminar histórico de publicação demográfica.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('Preserve demographic', $exception->getMessage());
}
$published = app(PortalEditorialWorkflow::class)->latestDemography($this->batch);
$this->action($published, 'archive', $this->reviewer);
$this->assertSame('no_data', $service->analyse([$this->province], 2026, 2026, 'general', $this->batch)['state']);
$this->assertSame('approved', DB::table('demographic_imports')->where('id', $this->legacy)->value('status'));
$this->assertSame($before, DB::table('demographic_observations')->whereIn('import_id', [$this->legacy, $this->batch])->orderBy('id')->get()->toJson());
}
public function test_changed_observations_or_original_block_publication_and_cannot_be_blessed_by_a_new_draft(): void
{
$edition = $this->approve($this->draft());
DB::table('demographic_observations')->where('import_id', $this->batch)->update(['numeric_value' => '999.999']);
$this->action($edition, 'publish', $this->reviewer, 409);
$this->getJson('/management/editorial/demography/'.$this->batch)->assertJsonPath('edition.fresh', false);
$this->draft(1, 409);
DB::table('demographic_observations')->where('import_id', $this->batch)->update(['numeric_value' => '110.250']);
Storage::disk('local')->put('demography/'.$this->batch.'/original.xlsx', 'SYNTHETIC CHANGED ORIGINAL');
$this->action($edition, 'publish', $this->reviewer, 409);
$this->assertSame('imported', DB::table('demographic_imports')->where('id', $this->batch)->value('status'));
}
public function test_changed_territorial_context_requires_a_new_decision_without_overwriting_numbers(): void
{
$edition = $this->approve($this->draft());
DB::table('geo_locations')->where('id', $this->province)->update(['name' => 'SYNTHETIC REVISED TERRITORY']);
$this->action($edition, 'publish', $this->reviewer, 409);
$next = $this->draft(1);
$this->assertNull($next->approval);
$this->assertSame('110.250', DB::table('demographic_observations')->where('import_id', $this->batch)->value('numeric_value'));
$next = $this->approve($next);
$this->action($next, 'publish', $this->reviewer);
}
public function test_authority_mfa_independence_original_download_and_legacy_bypass_are_enforced(): void
{
$edition = $this->draft();
$this->postJson('/management/demography/'.$this->batch.'/transition', ['action' => 'approved', 'reason' => 'Tentativa por operação antiga'])->assertConflict();
$this->get('/management/editorial/demography/'.$this->batch.'/original')->assertOk()->assertDownload('SYNTHETIC-EDITORIAL.xlsx');
$edition = $this->action($edition, 'submit', $this->editor);
$edition = $this->action($edition, 'start_review', $this->reviewer);
$this->action($edition, 'approve', $this->editor, 403);
$this->actingAs(User::factory()->create())->getJson('/management/editorial/demography/'.$this->batch)->assertForbidden();
$this->getJson('/management/editorial/demography/'.$this->batch.'/original')->assertForbidden();
$this->actingAsWithMfa($this->reviewer);
session()->forget('portal.mfa_at');
$this->postJson('/management/editorial/demography/'.$this->batch.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => 'approve', 'reason' => 'Tentativa sem MFA fresco'])->assertForbidden();
$edition = $this->action($edition, 'approve', $this->reviewer);
$publisher = User::factory()->create();
config(['portal.managers' => [$this->editor->email, $publisher->email]]);
$this->action($edition, 'publish', $publisher, 403);
config(['editorial_workflow.demography' => false]);
$this->actingAsWithMfa($this->editor)->postJson('/management/demography/'.$this->batch.'/transition', ['action' => 'approved', 'reason' => 'Tentativa após desactivação'])->assertStatus(503);
}
public function test_legacy_public_series_need_no_new_approval_and_notices_do_not_grant_access(): void
{
$this->actingAsWithMfa($this->editor);
$this->postJson('/management/editorial/demography/'.$this->legacy.'/drafts', ['expected_revision' => 0])->assertConflict();
$this->assertSame(0, DB::table('portal_editorial_editions')->where('demographic_import_id', $this->legacy)->count());
$edition = $this->action($this->draft(), 'submit', $this->editor);
$this->actingAsWithMfa($this->reviewer)->getJson('/management/editorial')->assertJsonPath('demographyPending', 1)->assertJsonPath('demographyUrl', '/management/demography')
->assertJsonPath('notices.0.href', '/management/editorial/demography/'.$this->batch.'?edition='.$edition->id);
config(['portal.managers' => [$this->editor->email]]);
$this->getJson('/management/editorial')->assertForbidden();
}
public function test_131400_observations_are_fingerprinted_and_published_with_bounded_php_memory(): void
{
DB::table('demographic_observations')->where('import_id', $this->batch)->delete();
DB::statement("INSERT INTO demographic_observations(import_id,geo_location_id,year,indicator,sex,residence,age_group,unit,data_kind,original_value,numeric_value,sheet,cell,validation_status) SELECT ?,?,2026,'population','all','all','SYNTHETIC-QA-'||i,'pessoas','projected','1','1','QA','B4','valid' FROM generate_series(1,131400) i", [$this->batch, $this->province]);
$report = json_decode(DB::table('demographic_imports')->where('id', $this->batch)->value('report'), true, 512, JSON_THROW_ON_ERROR);
$report['record_count'] = 131400;
$preview = json_encode(['source' => 'SYNTHETIC QA ONLY', 'record_count' => 131400], JSON_THROW_ON_ERROR);
Storage::disk('local')->put('demography/'.$this->batch.'/preview.json', $preview);
DB::table('demographic_imports')->where('id', $this->batch)->update(['report' => json_encode($report, JSON_THROW_ON_ERROR), 'preview_sha256' => hash('sha256', $preview)]);
$edition = $this->approve($this->draft());
$this->action($edition, 'publish', $this->reviewer);
$this->assertSame('approved', DB::table('demographic_imports')->where('id', $this->batch)->value('status'));
$this->assertSame(131400, DB::table('demographic_observations')->where('import_id', $this->batch)->count());
$this->assertLessThan(256 * 1024 * 1024, memory_get_peak_usage(true));
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Vite;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EditorialDashboardBuildTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
public function test_authenticated_full_html_opens_with_the_real_compiled_dashboard_entry(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build';
$manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
$key = 'resources/js/pages/management/editorial-workflow.tsx';
$this->assertArrayHasKey($key, $manifest, 'The authenticated dashboard must be a compiled page entry, not only a shared chunk.');
$this->app->make(Vite::class)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-no-vite-hot'));
config(['portal.qa_build_directory' => $directory, 'editorial_workflow.enabled' => true, 'portal.profile_access_enabled' => false]);
$actor = User::factory()->create();
config(['portal.managers' => [$actor->email]]);
$this->actingAsWithMfa($actor);
$nativeKey = 'resources/js/pages/dashboard.tsx';
$this->assertArrayHasKey($nativeKey, $manifest);
$this->get('/dashboard', ['Accept' => 'text/html'])->assertOk()->assertSee($manifest[$nativeKey]['file'], false);
foreach (['/management/editorial', '/management/editorial?kind=legislation'] as $url) {
$this->get($url, ['Accept' => 'text/html'])->assertOk()->assertSee('data-page=', false)->assertSee($manifest[$key]['file'], false);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use App\Services\ContentManagement;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialWorkflow;
use App\Services\TerritorialRegistry;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EditorialNativeNoticeTest extends TestCase
{
use AuthenticatesQaActors,DatabaseTransactions;
private User $author;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
[$this->author,$this->reviewer] = User::factory()->count(2)->create()->all();
config(['portal.managers' => [$this->author->email, $this->reviewer->email], 'portal.profile_access_enabled' => false, 'editorial_workflow.enabled' => true, 'editorial_workflow.cluster_competitiveness' => true, 'editorial_workflow.territorial_versions' => true, 'territorial.enabled' => true, 'territorial.editors' => [$this->author->email], 'territorial.reviewers' => [$this->reviewer->email], 'territorial.publishers' => [$this->reviewer->email]]);
$this->actingAsWithMfa($this->reviewer);
$this->actingAsWithMfa($this->author);
}
public function test_dashboard_navigation_uses_existing_scopes_and_does_not_expose_disabled_or_private_activities(): void
{
$service = app(ContentManagement::class);
$items = $service->dashboardNavigation($this->author, $service->access($this->author));
$this->assertSame(['label' => 'Visão geral', 'href' => '/dashboard'], $items[0]);
$this->assertNotContains('/management/clusters', array_column($items, 'href'));
$this->assertContains('/management/clusters', array_column($service->administrationNavigation($this->author, $service->access($this->author)), 'href'));
$this->assertContains('/dashboard#manifestacoes', array_column($items, 'href'));
$outsider = User::factory()->create();
$this->assertSame([], $service->dashboardNavigation($outsider, []));
$this->assertSame([], $service->dashboardNavigation(null, []));
config(['editorial_workflow.enabled' => false]);
$after = array_column($service->dashboardNavigation($this->author, $service->access($this->author)), 'href');
$this->assertContains('/dashboard', $after);
$this->assertNotContains('/management/editorial', $after);
}
public function test_cluster_events_use_the_common_inbox_and_recheck_current_scope_and_event_integrity(): void
{
$cluster = EconomicCluster::create(['code' => 'NOTICE-'.Str::random(12), 'name' => 'Synthetic notice cluster', 'sector' => 'Energy', 'status' => 'planned']);
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energy', 'region' => 'Synthetic territory', 'forces' => []];
foreach (ClusterCompetitiveness::FORCES as $force) {
$draft['forces'][$force] = ['pressure' => 'medium', ...array_fill_keys(ClusterCompetitiveness::FIELDS, 'Synthetic evidence and limitations')];
}
$service = app(ClusterCompetitiveness::class);
$service->change($cluster, $this->author->id, 0, 'saved', ['draft' => $draft]);
$this->assertDatabaseMissing('portal_editorial_notifications', ['user_id' => $this->reviewer->id, 'action' => 'saved']);
$state = $service->change($cluster, $this->author->id, 1, 'submitted', ['reason' => 'Synthetic review requested']);
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->where('action', 'submitted')->first();
$this->assertNotNull($notice);
$this->assertTrue(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $notice));
app(PortalEditorialNativeNotices::class)->emit($this->author, $notice->native_event_id, [$this->reviewer->id]);
$this->assertSame(1, DB::table('portal_editorial_notifications')->where('native_event_id', $notice->native_event_id)->where('user_id', $this->reviewer->id)->count());
$present = app(PortalEditorialNativeNotices::class)->present($notice);
$this->assertStringContainsString($cluster->id.'/edit?notice_version=2', $present['href']);
$this->assertSame('Competitividade — versão 2', $present['reference']);
$this->actingAsWithMfa($this->reviewer)->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertOk();
config(['portal.managers' => [$this->author->email]]);
$this->assertFalse(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $notice));
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertNotFound();
config(['portal.managers' => [$this->author->email, $this->reviewer->email]]);
DB::table('portal_editorial_notifications')->where('id', $notice->id)->update(['content_hash' => str_repeat('0', 64)]);
$tampered = DB::table('portal_editorial_notifications')->where('id', $notice->id)->first();
$this->assertFalse(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $tampered));
}
public function test_territory_events_open_the_correct_record_without_publishing_or_granting_access(): void
{
$source = (string) Str::uuid();
DB::table('geo_sources')->insert(['id' => $source, 'name' => 'Synthetic source', 'reference' => 'Synthetic source reference', 'checksum' => str_repeat('f', 64), 'source_version' => 'qa', 'collected_at' => today(), 'editor_id' => $this->author->id]);
$registry = app(TerritorialRegistry::class);
$id = $registry->save($this->author, ['name' => 'Synthetic country', 'code' => 'NOTICE-'.Str::random(12), 'location_type' => 'country', 'source_id' => $source, 'valid_from' => '2020-01-01', 'reason' => 'Synthetic territorial preparation']);
$baseline = app(PortalEditorialNativeNotices::class)->pending($this->reviewer);
$registry->transitionEditorial($this->author, $id, ['action' => 'pending_review', 'version_no' => 1, 'reason' => 'Synthetic territorial review requested']);
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->where('action', 'pending_review')->first();
$this->assertNotNull($notice);
$this->assertTrue(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $notice));
$this->assertStringContainsString('territory='.$id, app(PortalEditorialNativeNotices::class)->present($notice)['href']);
$this->actingAsWithMfa($this->reviewer)->get('/management/territorial/editorial?territory='.$id.'&notice_version=2')->assertOk();
$this->assertFalse($registry->published()->where('g.id', $id)->exists());
$registry->transitionEditorial($this->reviewer, $id, ['action' => 'in_review', 'version_no' => 2, 'reason' => 'Synthetic reviewer starts review']);
$this->assertSame($baseline['territories'], app(PortalEditorialNativeNotices::class)->pending($this->reviewer)['territories']);
$registry->transitionEditorial($this->reviewer, $id, ['action' => 'archived', 'version_no' => 3, 'reason' => 'Synthetic proposal archived']);
$this->assertSame($baseline['territories'] - 1, app(PortalEditorialNativeNotices::class)->pending($this->reviewer)['territories']);
$this->assertNotEmpty(app(PortalEditorialWorkflow::class)->notices($this->author));
}
public function test_email_designation_is_explicit_revocable_and_does_not_grant_content_management(): void
{
$outsider = User::factory()->create();
$workflow = app(PortalEditorialWorkflow::class);
$this->assertFalse($workflow->legalReviewer($outsider));
config(['editorial_workflow.legal_reviewer_emails' => [strtolower($outsider->email)]]);
$this->assertTrue($workflow->legalReviewer($outsider));
$this->assertFalse($outsider->can('manage-portal'));
$outsider->forceFill(['email_verified_at' => null])->save();
$this->assertFalse($workflow->legalReviewer($outsider));
config(['editorial_workflow.legal_reviewer_emails' => []]);
$this->assertFalse($workflow->legalReviewer($this->reviewer));
}
}
@@ -0,0 +1,207 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\Project;
use App\Models\User;
use App\Models\ValueChain;
use App\Models\ValueChainLink;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EditorialStructuralSubjectTest extends TestCase
{
use AuthenticatesQaActors,DatabaseTransactions;
private User $author;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
[$this->author,$this->reviewer] = User::factory()->count(2)->create()->all();
config(['portal.managers' => [$this->author->email, $this->reviewer->email], 'portal.profile_access_enabled' => false, 'editorial_workflow.enabled' => true, 'editorial_workflow.structural_subjects' => true]);
$this->actingAsWithMfa($this->author);
}
private function move(\stdClass $edition, string $action, ?User $actor = null): \stdClass
{
$actor ??= $action === 'submit' ? $this->author : $this->reviewer;
$this->actingAsWithMfa($actor);
return app(PortalEditorialWorkflow::class)->transition($actor, $edition->subject_key, $edition->id, ['action' => $action, 'version' => $edition->version, 'reason' => 'Synthetic subject review decision'], false, false, $edition->subject_kind);
}
private function approve(\stdClass $edition): \stdClass
{
foreach (['submit', 'start_review', 'approve'] as $action) {
$edition = $this->move($edition, $action);
}
return $edition;
}
public function test_a_structure_without_a_fiche_keeps_its_public_fields_until_explicit_publication(): void
{
$record = Project::create(['code' => 'SUB-'.Str::random(10), 'name' => 'Original public name', 'status' => 'active', 'data_classification' => 'public']);
$before = $record->fresh()->getAttributes();
$dossiersBefore = DB::table('investment_dossiers')->count();
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'projects', $record->id, 1, ['name' => 'Proposed private name', 'budget_amount' => '999999999999999999.99', 'budget_currency' => 'MZN']);
$this->assertEquals($before, $record->fresh()->getAttributes());
$this->assertSame(2, $edition->revision_no);
$edition = $this->approve($edition);
$this->assertEquals($before, $record->fresh()->getAttributes());
$edition = $this->move($edition, 'publish');
$this->assertSame('Proposed private name', $record->fresh()->name);
$this->assertSame('999999999999999999.99', $record->fresh()->getRawOriginal('budget_amount'));
$this->assertDatabaseCount('investment_dossiers', $dossiersBefore);
$this->assertSame('published', $edition->state);
}
public function test_relation_edits_do_not_invalidate_or_replace_the_current_public_association_before_publication(): void
{
$project = Project::create(['code' => 'REL-'.Str::random(10), 'name' => 'Synthetic project', 'status' => 'active']);
$cluster = EconomicCluster::create(['code' => 'REL-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$id = (string) Str::uuid();
DB::table('cluster_projects')->insert(['id' => $id, 'cluster_id' => $cluster->id, 'project_id' => $project->id, 'role' => 'anchor', 'association_type' => 'potential', 'source_reference' => 'Synthetic identified source', 'evidence_reference' => 'Synthetic page 2', 'valid_from' => '2020-01-01', 'validation_status' => 'validated', 'editor_id' => $this->author->id, 'validator_id' => $this->reviewer->id, 'version_no' => 1, 'data_classification' => 'public']);
$before = DB::table('cluster_projects')->find($id);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'cluster-project', $id, 1, ['role' => 'supporting']);
$edition = $this->move($edition, 'submit');
$edition = $this->move($edition, 'start_review');
try {
$this->move($edition, 'approve', $this->author);
$this->fail('Contributor approval must be rejected');
} catch (HttpException $e) {
$this->assertSame(403, $e->getStatusCode());
}
$edition = $this->move($edition, 'approve');
$this->assertEquals($before, DB::table('cluster_projects')->find($id));
$this->move($edition, 'publish');
$this->assertSame('supporting', DB::table('cluster_projects')->find($id)->role);
$this->assertSame('validated', DB::table('cluster_projects')->find($id)->validation_status);
}
public function test_new_value_chain_is_private_until_published_and_archiving_keeps_its_history(): void
{
$cluster = EconomicCluster::create(['code' => 'CHAIN-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$chain = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'QA-'.Str::random(8), 'name' => 'Private new chain', 'stage' => 'production', 'deleted_at' => now()]);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'value-chains', $chain->id, 0, [], true);
$this->assertNull(ValueChain::find($chain->id));
$edition = $this->approve($edition);
$this->assertNull(ValueChain::find($chain->id));
$edition = $this->move($edition, 'publish');
$this->assertNotNull(ValueChain::find($chain->id));
$edition = $this->move($edition, 'archive');
$this->assertNull(ValueChain::find($chain->id));
$this->assertNotNull(app(PortalEditorialWorkflow::class)->latestSubject('value-chains', $chain->id));
}
public function test_existing_editor_routes_and_private_subject_endpoints_preserve_context_and_permissions(): void
{
$record = Project::create(['code' => 'HTTP-'.Str::random(10), 'name' => 'Original HTTP name', 'status' => 'active']);
$this->put('/management/projects/'.$record->id, ['editorial_version' => 1, 'code' => $record->code, 'name' => 'Private HTTP name', 'status' => 'active'])->assertRedirect()->assertSessionHasNoErrors();
$this->assertSame('Original HTTP name', $record->fresh()->name);
$base = '/management/editorial/subjects/projects/'.$record->id;
$this->getJson($base)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('edition.payload.resource._subject.fields.name', 'Private HTTP name');
$this->getJson('/management/editorial/subjects')->assertOk();
$this->getJson($base.'/choices?field=primary_territory_id')->assertOk();
$this->getJson($base.'/choices?field=password')->assertNotFound();
$this->postJson($base.'/drafts', ['expected_revision' => 2, 'fields' => ['data_classification' => 'public']])->assertUnprocessable();
$this->postJson($base.'/drafts', ['expected_revision' => 2, 'fields' => ['name' => 'Deliberate revised name']])->assertCreated();
$this->getJson($base)->assertJsonPath('edition.payload.resource._subject.fields.name', 'Deliberate revised name');
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->getJson($base)->assertForbidden();
$this->postJson($base.'/drafts', ['expected_revision' => 3, 'fields' => ['name' => 'Forbidden']])->assertForbidden();
$this->assertSame('Original HTTP name', $record->fresh()->name);
}
public function test_withdrawal_of_an_existing_chain_link_is_a_separate_reviewed_publication(): void
{
$cluster = EconomicCluster::create(['code' => 'WITHDRAW-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$a = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'A-'.Str::random(5), 'name' => 'Origin', 'stage' => 'production', 'data_classification' => 'public']);
$b = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'B-'.Str::random(5), 'name' => 'Destination', 'stage' => 'processing', 'data_classification' => 'public']);
$link = ValueChainLink::create(['from_value_chain_id' => $a->id, 'to_value_chain_id' => $b->id, 'link_type' => 'supplies', 'data_classification' => 'public']);
$this->delete('/management/clusters/'.$cluster->id.'/chain-links/'.$link->id)->assertRedirect('/management/editorial/subjects/value-chain-links/'.$link->id);
$this->assertNotNull(ValueChainLink::find($link->id));
$edition = app(PortalEditorialWorkflow::class)->latestSubject('value-chain-links', $link->id);
$this->assertTrue(app(PortalEditorialWorkflow::class)->payload($edition)['resource']['_subject']['withdraw']);
$edition = $this->approve($edition);
$this->assertNotNull(ValueChainLink::find($link->id));
$this->move($edition, 'publish');
$this->assertNull(ValueChainLink::find($link->id));
$this->getJson('/management/editorial/subjects/value-chain-links/'.$link->id)->assertOk();
}
public function test_new_chain_form_creates_a_private_proposal_in_the_existing_model(): void
{
$cluster = EconomicCluster::create(['code' => 'FORM-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$code = 'NEW-'.Str::random(10);
$this->post('/management/clusters/'.$cluster->id.'/chains', ['code' => $code, 'name' => 'Private new stage', 'stage' => 'production'])->assertRedirect()->assertSessionHasNoErrors();
$chain = ValueChain::withTrashed()->where('code', $code)->firstOrFail();
$this->assertNull(ValueChain::find($chain->id));
$edition = app(PortalEditorialWorkflow::class)->latestSubject('value-chains', $chain->id);
$this->assertSame('draft', $edition->state);
$this->getJson('/management/editorial/subjects/value-chains/'.$chain->id)->assertOk()->assertJsonPath('edition.payload.resource._subject.new_record', true);
}
public function test_duplicate_relation_publication_is_refused_and_keeps_both_original_links(): void
{
$cluster = EconomicCluster::create(['code' => 'DUP-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$chains = collect(['A', 'B', 'C'])->map(fn ($code) => ValueChain::create(['cluster_id' => $cluster->id, 'code' => $code, 'name' => $code, 'stage' => 'production']));
$existing = ValueChainLink::create(['from_value_chain_id' => $chains[0]->id, 'to_value_chain_id' => $chains[1]->id, 'link_type' => 'supplies']);
$edited = ValueChainLink::create(['from_value_chain_id' => $chains[0]->id, 'to_value_chain_id' => $chains[2]->id, 'link_type' => 'supplies']);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'value-chain-links', $edited->id, 1, ['to_value_chain_id' => $chains[1]->id]);
$edition = $this->approve($edition);
try {
$this->move($edition, 'publish');
$this->fail('Duplicate must be refused before writing');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame($chains[2]->id, $edited->fresh()->to_value_chain_id);
$this->assertNotNull($existing->fresh());
}
public function test_partial_edits_validate_against_the_preserved_dates_and_currency(): void
{
$record = Project::create(['code' => 'DATES-'.Str::random(10), 'name' => 'Original', 'status' => 'active', 'start_date' => '2026-05-01']);
$base = '/management/editorial/subjects/projects/'.$record->id.'/drafts';
$this->postJson($base, ['expected_revision' => 1, 'fields' => ['end_date' => '2026-04-01']])->assertUnprocessable();
$this->postJson($base, ['expected_revision' => 1, 'fields' => ['budget_amount' => '2000.25']])->assertUnprocessable();
$this->assertSame(0, DB::table('portal_editorial_editions')->where('subject_key', $record->id)->count());
$this->assertSame('Original', $record->fresh()->name);
}
public function test_context_changes_and_disabled_workflow_do_not_allow_legacy_bypass(): void
{
$record = Project::create(['code' => 'STALE-'.Str::random(10), 'name' => 'Original', 'status' => 'active']);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'projects', $record->id, 1, ['name' => 'Private']);
$edition = $this->approve($edition);
$record->forceFill(['description' => 'Changed contextual evidence'])->save();
try {
$this->move($edition, 'publish');
$this->fail('Context changed');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame('Original', $record->fresh()->name);
config(['editorial_workflow.structural_subjects' => false]);
try {
app(PortalEditorialSubjects::class)->writes('projects', $record->id);
$this->fail('Legacy bypass');
} catch (HttpException $e) {
$this->assertSame(503, $e->getStatusCode());
}
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Models\DevelopmentCorridor;
use App\Models\InvestmentDossier;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class GlobalSearchCountTest extends TestCase
{
use DatabaseTransactions;
public function test_licensing_question_opens_existing_guidance_without_mixing_catalogue_matches(): void
{
$this->withoutVite();
$this->get('/pesquisa?'.http_build_query(['q' => 'como licenciar uma empresa']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.type', 'guide')->where('results.total', 2)
->where('results.data.0.url', '/como-investir#planeamento')
->where('results.data.1.url', '/investimentos/iniciar?step=S08'));
}
public function test_physical_product_search_keeps_the_product_and_documented_location_in_its_link(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
config(['portal.legacy_content_enabled' => false, 'portal.legacy_content_qa_enabled' => false]);
$sector = Sector::firstOrCreate(['slug' => 'mineracao'], ['name' => 'Mineração']);
$sector->forceFill(['is_active' => true, 'published_at' => now()->subMinute()])->save();
$this->get('/pesquisa?'.http_build_query(['q' => 'onde encontrar grafite em Cabo Delgado']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.type', 'products')->where('results.total', 1)->where('results.unavailable', [])
->where('results.data.0.title', 'Grafite')->where('results.data.0.category', 'Produtos e localizações')
->where('results.data.0.location', fn ($text) => str_contains($text, 'Cabo Delgado'))
->where('results.data.0.url', fn ($url) => str_contains($url, 'resource_group=') && str_contains($url, 'resource_location=Cabo+Delgado')));
$this->get('/pesquisa?'.http_build_query(['q' => 'grafite Cabo Delgado', 'category' => 'physical']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.category', 'physical')->where('results.total', 1)
->where('results.data.0.category', 'Produtos e localizações'));
$this->get('/pesquisa?category=invalid')->assertRedirect();
}
public function test_financial_product_search_excludes_drafts_and_future_publications_and_distinguishes_coverage(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
if (! Schema::hasTable('funding_programmes')) {
// Only this transaction in the isolated QA database; no active schema changes.
(require database_path('migrations/2026_11_02_210000_funding_programme_catalogue.php'))->up();
}
$actor = User::factory()->create();
$marker = 'SYNTHFIN'.Str::random(8);
foreach (['published', 'draft', 'future', 'historical', 'merged'] as $state) {
$id = (string) Str::uuid();
$version = (string) Str::uuid();
$payload = json_encode(['title' => $marker.' '.$state, 'kind' => 'institution', 'summary' => 'Nota de preparação: ficha e documento gerador.',
'institution_nature' => 'bank', 'institution_services' => ['investment'],
'institution_status' => in_array($state, ['historical', 'merged'], true) ? $state : 'reported',
'locations' => [['location_text' => 'Nampula']], 'products' => [['title' => 'Crédito PME', 'coverage' => 'national', 'needs' => ['investment']]]], JSON_THROW_ON_ERROR);
DB::table('funding_programmes')->insert(['id' => $id, 'code' => $marker.$state, 'draft' => $payload, 'created_by' => $actor->id]);
DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => 1, 'payload' => $payload,
'reason' => 'Synthetic QA only', 'actor_id' => $actor->id, 'published_at' => $state === 'future' ? now()->addDay() : now()->subMinute()]);
if ($state !== 'draft') {
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version]);
}
}
$this->get('/pesquisa?'.http_build_query(['q' => 'produtos financeiros '.$marker.' em Nampula']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.type', 'products')->where('results.total', 1)->where('results.unavailable', [])
->where('results.data.0.category', 'Produtos financeiros')->where('results.data.0.description', 'Crédito PME')
->where('results.data.0.location', 'Presença física: Nampula')
->where('results.data.0.coverage', fn ($text) => str_contains($text, 'nacional')));
}
public function test_corridor_count_excludes_other_types_drafts_and_deleted_entities(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
$marker = 'SYNTHCOUNT'.Str::random(10);
foreach (range(1, 4) as $n) {
$corridor = DevelopmentCorridor::create(['code' => $marker.$n, 'name' => $marker.$n, 'status' => 'active', 'data_classification' => 'public']);
InvestmentDossier::create(['corridor_id' => $corridor->id, 'title' => $marker.' '.$n,
'content' => [], 'source' => 'Synthetic QA only', 'publication_status' => $n === 3 ? 'draft' : 'published',
'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA only']);
if ($n === 4) {
$corridor->delete();
}
}
InvestmentDossier::create(['project_id' => Project::factory()->create()->id, 'title' => $marker.' corredor relacionado',
'content' => [], 'source' => 'Synthetic QA only', 'publication_status' => 'published', 'published_at' => now(),
'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA only']);
$query = 'quantos corredores existem '.$marker;
$this->get('/pesquisa?'.http_build_query(['q' => $query]))->assertOk()->assertInertia(fn (Assert $page) => $page
->component('portal/search')->where('query', $query)->where('answer.count', 2)->where('answer.label', 'corredores')
->where('results.total', 2)->has('results.data', 2)->where('results.unavailable', []));
$this->get('/pesquisa?'.http_build_query(['q' => 'quantos corredores existem '.$marker.' inexistente']))
->assertOk()->assertInertia(fn (Assert $page) => $page->where('answer.count', 0)->where('results.total', 0));
$this->get('/pesquisa?q=quantos+corredores+existem')->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.label', 'corredores')->where('answer.count', fn ($count) => $count >= 2)
->where('results.unavailable', []));
}
}
+13
View File
@@ -7,6 +7,7 @@ use App\Services\FederatedSearch;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class GlobalSearchTest extends TestCase
@@ -35,6 +36,18 @@ final class GlobalSearchTest extends TestCase
$this->assertSame('Como investir', $result['data'][0]['title']);
}
public function test_unavailable_public_reference_does_not_hide_other_sources_or_swallow_forbidden_access(): void
{
$result = app(FederatedSearch::class)->page([
'Referências' => fn () => throw new HttpException(503),
'Páginas' => fn () => ['count' => 1, 'fetch' => fn () => [['title' => 'Como investir', 'url' => '/como-investir']]],
], 1);
$this->assertSame(1, $result['total']);
$this->assertSame(['Referências'], $result['unavailable']);
$this->expectException(HttpException::class);
app(FederatedSearch::class)->page(['Restrito' => fn () => throw new HttpException(403)], 1);
}
public function test_public_search_uses_the_existing_portfolio_visibility_scope(): void
{
if (getenv('GLOBAL_SEARCH_QA') !== '1') {
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\InvestmentZone;
use App\Models\User;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneIntegralDetails;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalProfile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class InvestmentObservationNatureTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
Http::preventStrayRequests();
}
private function observation(?string $kind): array
{
$item = ['id' => (string) Str::uuid(), 'label' => 'SYNTHETIC nature QA', 'description' => 'Synthetic source only.',
'indicator' => 'employment', 'value' => '0', 'unit' => 'pessoas', 'period_start' => '2025-01-01', 'period_end' => '2025-12-31',
'scope' => 'Synthetic zone', 'population' => 'Synthetic establishments', 'methodology' => 'Synthetic register',
'source_reference' => 'SYNTHETIC source; not real statistics', 'source_date' => '2026-01-15', 'knowledge' => 'documented', 'disclosure' => true];
return $kind === null ? $item : [...$item, 'measure_kind' => $kind];
}
private function zone(): InvestmentZone
{
$actor = User::factory()->create(['email' => 'measure-'.Str::uuid().'@example.test']);
return InvestmentZone::create(['code' => 'MEASURE-QA-'.Str::uuid(), 'category' => 'industrial_park', 'name' => 'SYNTHETIC nature QA',
'editor_id' => $actor->id, 'publication_status' => 'draft', 'validation_status' => 'pending']);
}
public function test_explicit_natures_persist_recover_and_project_without_inference_or_private_disclosure(): void
{
$zone = $this->zone();
$service = app(InvestmentZoneIntegralDetails::class);
$ops = app(InvestmentZoneOperationalDetails::class);
$rows = array_map(fn ($kind) => $this->observation($kind), ['observed', 'planned', 'projected', null]);
$hidden = [...$this->observation('observed'), 'disclosure' => false, 'source_reference' => 'PRIVATE-NATURE-SOURCE'];
$merged = $service->merge($zone, [], ['observations' => [...$rows, $hidden]]);
$zone->forceFill(['operational_details' => $ops->pack(['integral' => $merged])])->save();
$saved = $zone->fresh();
$before = $saved->getRawOriginal('operational_details');
$recovered = $ops->recover($saved)['integral']['observations'];
$this->assertSame(['observed', 'planned', 'projected'], array_column(array_slice($recovered, 0, 3), 'measure_kind'));
$this->assertArrayNotHasKey('measure_kind', $recovered[3]);
$this->assertSame('0', $recovered[0]['value']);
$view = app(InvestmentMapIndicators::class)->forZones(collect([$saved]));
$this->assertSame(['observed', 'planned', 'projected', 'unknown'], array_column($view['records'], 'measure_kind'));
$this->assertSame(['0', '0', '0', '0'], array_column($view['records'], 'value'));
$this->assertStringNotContainsString('PRIVATE-NATURE-SOURCE', json_encode($view, JSON_THROW_ON_ERROR));
$this->assertSame($before, $zone->fresh()->getRawOriginal('operational_details'));
$this->assertSame(1, $zone->fresh()->version_no);
}
public function test_invalid_nature_is_rejected_and_an_omitted_nature_does_not_erase_an_explicit_choice(): void
{
$zone = $this->zone();
$service = app(InvestmentZoneIntegralDetails::class);
$row = $this->observation('observed');
$before = $service->merge($zone, [], ['observations' => [$row]]);
$withoutKind = $row;
unset($withoutKind['measure_kind']);
$recovered = $service->merge($zone, $before, ['observations' => [$withoutKind]]);
$this->assertSame('observed', $recovered['observations'][0]['measure_kind']);
$changed = $service->merge($zone, $before, ['observations' => [[...$row, 'measure_kind' => 'planned']]]);
$this->assertSame('planned', $changed['observations'][0]['measure_kind']);
$this->assertSame('observed', $before['observations'][0]['measure_kind']);
try {
$service->merge($zone, $before, ['observations' => [[...$row, 'measure_kind' => 'inferred_from_description']]]);
$this->fail('Invalid nature accepted');
} catch (ValidationException $error) {
$this->assertArrayHasKey('observations.0.measure_kind', $error->errors());
}
}
public function test_nature_change_stays_in_private_preparation_with_history_until_existing_editorial_review(): void
{
$zone = $this->zone();
$ops = app(InvestmentZoneOperationalDetails::class);
$row = $this->observation('observed');
$fields = app(InvestmentZoneIntegralDetails::class)->merge($zone, [], ['observations' => [$row]]);
// Synthetic public snapshot only, inside the rolled-back QA transaction.
$zone->forceFill(['publication_status' => 'published', 'validation_status' => 'validated',
'reviewer_id' => User::factory()->create(['email' => 'nature-review-'.Str::uuid().'@example.test'])->id,
'published_at' => now(), 'physical_category' => 'industrial_park',
'source_evidence' => [['source_type' => 'promoter', 'title' => 'SYNTHETIC QA only', 'url' => 'https://example.invalid/qa', 'supports' => 'Synthetic test fixture']],
'operational_details' => $ops->pack(['integral' => $fields])])->save();
$original = $zone->fresh()->getRawOriginal('operational_details');
$profile = app(InvestmentZoneOperationalProfile::class);
$prepared = $profile->prepare($zone->fresh(), ['version_no' => 1,
'operational_details' => ['integral' => ['observations' => [[...$row, 'measure_kind' => 'planned']]]]], $zone->editor_id);
$this->assertSame($original, $prepared->getRawOriginal('operational_details'));
$this->assertSame('observed', $ops->publicData($prepared)['integral']['observations'][0]['measure_kind']);
$this->assertSame('planned', $profile->draft($prepared)['fields']['operational_details']['integral']['observations'][0]['measure_kind']);
$this->assertSame(1, $prepared->version_no);
$this->assertSame(1, DB::table('investment_zone_revisions')->where('zone_id', $zone->id)->where('action', 'profile_prepared')->count());
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $zone->id)->where('action', 'profile_prepared')->count());
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcAdvanceCashflowDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_pec_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('investmoz_demography_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
$this->actingAs($actor);
$id = null;
$etag = null;
foreach (['overlap' => 138, 'distributed' => 138, 'partial' => 138, 'zero' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-cashflow-group-20261113/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame($book['ppcObligation'], $book['result']['model']['input']['ppcObligation']);
$this->assertSame('2026', $book['ppcObligation']['year']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-cashflow-group-20261113/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcAdvancePeriodDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_period_survives_native_recovery_without_changing_the_independent_annual_result(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('investmoz_demography_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
$this->actingAs($actor);
$id = null;
$etag = null;
foreach (['ordinary', 'noncivil', 'short', 'start'] as $name) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-period-consistency-20261113/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertArrayHasKey('advancePeriod', $book['result']['model']);
$this->assertSame($book['history'][0]['model']['arithmeticBalance'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame($book['ppcObligation'], $book['result']['model']['input']['ppcObligation']);
$this->assertSame('2026', $book['ppcObligation']['year']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-period-consistency-20261113/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcAnnualNoncivilDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_period_survives_native_recovery_without_changing_the_independent_annual_result(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('investmoz_demography_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
$this->actingAs($actor);
$id = null;
$etag = null;
foreach (['three', 'single', 'missing', 'changed'] as $name) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-noncivil-20261114/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertArrayHasKey('advancePeriod', $book['result']['model']);
$this->assertSame($book['history'][0]['model']['arithmeticBalance'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame($book['ppcObligation'], $book['result']['model']['input']['ppcObligation']);
$this->assertSame('2026', $book['ppcObligation']['year']);
$this->assertSame($book['advancePeriodDeclaration'], $book['result']['model']['input']['advancePeriodDeclaration']);
$this->assertSame('SYNTHETIC effective activity2020', $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-noncivil-20261114/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPecLastAmountDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_pec_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('investmoz_demography_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
$this->actingAs($actor);
$id = null;
$etag = null;
foreach (['documented' => 138, 'missing' => 138, 'positive' => 138, 'excess' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-pec-last-amount-20261113/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame('42500.25', $book['pecObligation']['lastAmount']['amount']);
$this->assertSame('2024', $book['pecObligation']['lastAmount']['year']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-pec-last-amount-20261113/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -160,6 +160,82 @@ class OpportunityCanonicalWorkflowTest extends TestCase
$this->assertNotNull($response['materiality']);
}
public function test_phase_one_requires_a_new_legal_decision_for_each_content_and_attachment_version(): void
{
config(['editorial_workflow.enabled' => true]);
$this->withoutVite();
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture();
$this->action($promoter, $id, 'submit');
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
}
$this->action($validator, $id, 'approve');
$signed = Opportunity::findOrFail($id)->workflow_context['reviews']['registry_urban_review'];
$this->action($promoter, $id, 'request_revision');
$payload['official_title'] .= ' — edição privada da Fase 1';
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
$this->action($promoter, $id, 'submit');
$context = Opportunity::findOrFail($id)->workflow_context;
$this->assertContains('registry_urban_review', $context['materiality']['reopened']);
$this->assertArrayNotHasKey('registry_urban_review', $context['reviews']);
$this->assertSame($signed['hash'], $context['decision_history'][0]['reviews']['registry_urban_review']['hash']);
$this->action($validator, $id, 'approve', [], 422);
$this->action($validator, $id, 'review_gate', ['gate' => 'registry_urban_review', 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
$this->action($validator, $id, 'approve');
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
$file = DB::table('opportunity_documents')->where('id', $document)->value('document_id');
DB::table('documents')->where('id', $file)->increment('version_no');
$this->action($publisher, $id, 'publish', [], 409);
}
public function test_phase_one_activation_preserves_an_approved_legacy_submission_without_rewriting_its_hash(): void
{
config(['editorial_workflow.enabled' => false]);
$this->withoutVite();
[$id, $promoter, $validator, $publisher, , $document] = $this->fixture();
$this->action($promoter, $id, 'submit');
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
}
$this->action($validator, $id, 'approve');
$approved = Opportunity::findOrFail($id)->workflow_context['approval']['hash'];
config(['editorial_workflow.enabled' => true]);
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
$this->action($publisher, $id, 'publish');
$record = Opportunity::findOrFail($id);
$this->assertSame('published', $record->status);
$this->assertSame($approved, $record->workflow_context['approval']['hash']);
$this->assertArrayNotHasKey('editorial_version_binding', $record->workflow_context);
}
public function test_phase_one_opportunity_notices_follow_current_access_and_do_not_grant_resource_access(): void
{
config(['editorial_workflow.enabled' => true]);
$this->withoutVite();
[$id, $promoter, $validator] = $this->fixture();
$this->action($promoter, $id, 'submit');
$this->action($validator, $id, 'start_review');
$notice = DB::table('portal_editorial_notifications')->where('user_id', $promoter->id)->where('opportunity_id', $id)->first();
$this->assertNotNull($notice);
$this->assertNotNull($notice->opportunity_version);
$data = $this->loginFixture($promoter)->getJson('/management/editorial')->assertOk()->assertJsonPath('resourceAccess', false)->assertJsonPath('items.total', 0)->json();
$this->assertNotEmpty($data['notices']);
$this->assertSame('/minhas-oportunidades/'.$id, $data['notices'][0]['href']);
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertOk();
$this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice->id)->value('read_at'));
$administrator = User::factory()->create();
config(['portal.managers' => [$administrator->email]]);
DB::table('portal_editorial_notifications')->insert(['user_id' => $administrator->id, 'edition_id' => null, 'opportunity_id' => $id,
'opportunity_version' => $notice->opportunity_version, 'action' => 'start_review', 'created_at' => now()]);
$this->loginFixture($administrator)->getJson('/management/editorial')->assertOk()->assertJsonPath('resourceAccess', true)->assertJsonPath('notices', [])->assertJsonPath('opportunityPending', 0);
$adminNotice = DB::table('portal_editorial_notifications')->where('user_id', $administrator->id)->value('id');
$this->postJson('/management/editorial/notices/'.$adminNotice.'/read')->assertNotFound();
$this->loginFixture($promoter);
DB::table('portal_role_assignments')->where('user_id', $promoter->id)->update(['revoked_at' => now()]);
$this->getJson('/management/editorial')->assertForbidden();
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertNotFound();
}
public function test_geometry_order_is_material_and_object_key_order_is_not(): void
{
$materiality = app(OpportunityReviewMateriality::class);
@@ -0,0 +1,304 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class PortalEditorialWorkflowTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $author;
private User $reviewer;
private User $legal;
private User $publisher;
private int $resource;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
if (! Schema::hasTable('portal_editorial_editions')) {
(require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->up();
}
$this->assertTrue(Schema::hasColumns('portal_editorial_editions', ['payload', 'content_hash', 'state', 'version', 'approval', 'legal_validation']));
if (! Schema::hasColumn('portal_editorial_notifications', 'opportunity_id')) {
(require database_path('migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php'))->up();
}
config(['editorial_workflow.enabled' => true, 'editorial_workflow.independent_approval.kinds' => ['article', 'legislation', 'document']]);
$this->withoutVite();
Storage::fake('local');
[$this->author, $this->reviewer, $this->legal, $this->publisher] = User::factory()->count(4)->create()->all();
config(['portal.managers' => [$this->author->email, $this->reviewer->email, $this->legal->email, $this->publisher->email], 'editorial_workflow.legal_reviewers' => [$this->legal->id]]);
$document = $this->document('Conteúdo público original.');
$this->resource = DB::table('portal_resources')->insertGetId(['document_id' => $document, 'title' => 'Artigo sintético QA', 'category' => 'guide', 'topic' => 'qa', 'document_type' => 'guide',
'published' => true, 'approved_by' => $this->reviewer->id, 'created_by' => $this->author->id, 'source_reference' => 'Fonte sintética de teste isolado', 'source_url' => 'https://example.invalid/source',
'source_confirmation' => 'identified', 'rights_status' => 'authorized', 'independent_review' => true, 'metadata_version' => 1,
'editorial_content' => json_encode(['body' => 'Conteúdo público original.', 'image_mode' => 'none', 'preserve_original_html' => false]), 'created_at' => now(), 'updated_at' => now()]);
}
private function document(string $body): string
{
$id = (string) Str::uuid();
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'content_html' => '<p>'.$body.'</p>']);
$hash = hash('sha256', $bytes);
Storage::disk('local')->put('qa/'.$id.'.json', $bytes);
DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic.json', 'media_type' => 'application/json', 'storage_key' => 'qa/'.$id.'.json',
'sha256' => $hash, 'malware_scanned_sha256' => $hash, 'malware_scan_status' => 'clean', 'rights_status' => 'authorized', 'status' => 'active', 'data_classification' => 'public', 'source_system' => 'portal-services', 'uploaded_by' => $this->author->id]);
return $id;
}
private function save(array $fields = []): \stdClass
{
$this->actingAsWithMfa($this->author);
$resource = DB::table('portal_resources')->where('id', $this->resource)->first();
return app(PortalEditorialWorkflow::class)->save($this->author, $this->resource, (int) app(PortalEditorialWorkflow::class)->workingResource($resource)->metadata_version, $fields);
}
private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = [])
{
$this->actingAsWithMfa($actor);
$response = $this->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão sintética fundamentada QA', ...$extra])->assertStatus($status);
return $status === 200 ? DB::table('portal_editorial_editions')->where('id', $edition->id)->first() : $response;
}
public function test_editing_and_private_preview_preserve_the_public_article_and_its_original_version(): void
{
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Nova edição privada.', 'image_mode' => 'none'])->assertRedirect();
$r = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->assertEquals($before, $r, 'The complete public resource must remain unchanged while a private edition is saved.');
$this->assertTrue((bool) $r->published);
$this->assertSame(1, (int) $r->metadata_version, 'A private edit must not change even the public metadata version.');
$this->assertSame('Conteúdo público original.', json_decode($r->editorial_content, true)['body']);
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
$this->get('/management/editorial/resources/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('current', true)->where('canApprove', false)->where('resource.public_edition_no', 1));
$this->get('/management/portal-services/resources/'.$this->resource.'/article/preview')->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.'));
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
$this->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Resposta atrasada.', 'image_mode' => 'none'])->assertConflict();
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
$this->action($this->publisher, $e, 'publish');
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.'));
}
public function test_legal_approval_and_publication_are_separate_version_bound_operations(): void
{
$e = $this->save(['title' => 'Legislação revista QA', 'legislation' => true, 'document_type' => 'legislation', 'source_confirmation' => 'official_confirmed']);
$e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]);
$e = $this->action($this->reviewer, $e, 'start_review');
$this->action($this->author, $e, 'approve', 403);
$this->action($this->reviewer, $e, 'approve', 422);
$this->action($this->reviewer, $e, 'legal_validate', 403);
$e = $this->action($this->legal, $e, 'legal_validate');
$e = $this->action($this->reviewer, $e, 'approve');
$this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
$this->assertSame('approved', $e->state);
$approved = $e;
$e = $this->action($this->publisher, $e, 'publish');
$this->assertSame('published', $e->state);
$this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
$this->action($this->publisher, $approved, 'publish', 409);
$draft = $this->save(['title' => 'Alteração posterior privada']);
$this->assertNull($draft->approval);
$this->assertNull($draft->legal_validation);
$this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
$this->actingAsWithMfa($this->author)->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.id', $draft->id)->assertJsonPath('resource.published', true);
$this->assertGreaterThan(0, DB::table('portal_editorial_notifications')->where('user_id', $this->author->id)->count());
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'editorial_publish')->count());
$this->getJson('/management/editorial/resources/'.$this->resource.'?edition='.$approved->id)->assertOk()->assertJsonPath('current', false)->assertJsonPath('edition.payload.resource.title', 'Legislação revista QA');
}
public function test_replacing_an_attachment_invalidates_approval_and_private_file_routes_are_scoped(): void
{
$doc = $this->document('Anexo novo, apenas na edição privada.');
$e = $this->save(['document_id' => $doc]);
$url = '/management/editorial/resources/'.$this->resource.'/editions/'.$e->id.'/files/'.$doc;
$this->get($url)->assertOk()->assertHeader('Cache-Control', 'no-store, private');
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->getJson($url)->assertForbidden();
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
DB::table('documents')->where('id', $doc)->increment('version_no');
$this->action($this->publisher, $e, 'publish', 409);
$this->actingAsWithMfa($this->author)->getJson($url)->assertConflict();
$this->assertNotSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
}
public function test_returns_rejection_archive_permissions_and_legacy_publication_cannot_bypass_review(): void
{
$e = $this->save(['title' => 'Edição para devolver']);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'return');
$this->assertSame('returned', $e->state);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'reject');
$e = $this->action($this->author, $e, 'archive');
$this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published'));
$this->actingAsWithMfa($this->publisher)->post('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertConflict();
$this->actingAs(User::factory()->create())->getJson('/management/editorial')->assertForbidden();
$this->actingAs($this->author)->withSession(['portal.mfa_at' => 0])->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$e->id, ['action' => 'submit', 'version' => $e->version, 'reason' => 'MFA expirado'])->assertForbidden();
}
public function test_document_metadata_replacement_remains_private_and_is_recovered_with_its_original_file(): void
{
$doc = $this->document('Documento de substituição privado.');
DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']);
$fields = ['document_id' => $doc, 'title' => 'Documento revisto QA', 'description' => 'Descrição privada', 'category' => 'guide', 'topic' => 'qa',
'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable',
'rights_status' => 'authorized', 'independent_review' => true, 'source_reference' => 'Ensaio sintético de referência documental', 'source_url' => 'https://example.invalid/document',
'legislation' => false, 'essential' => false, 'data_classification' => 'public', 'metadata_version' => 1, 'issuer' => 'Emissor sintético QA'];
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource, $fields)->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame($before->document_id, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
$this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.payload.resource.title', $fields['title'])->assertJsonPath('edition.payload.resource.document_id', $doc);
$this->get('/documentos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('resource.title', $before->title));
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
$e = $this->action($this->publisher, $e, 'publish');
$this->assertSame('public', DB::table('documents')->where('id', $doc)->value('data_classification'));
$this->assertSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
$this->assertSame('Emissor sintético QA', DB::table('documents')->where('id', $doc)->value('issuer'));
}
public function test_notice_receipt_never_grants_access_and_revocation_blocks_publication(): void
{
$e = $this->save();
$e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]);
$this->actingAsWithMfa($this->reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id);
$this->getJson('/management/editorial?state=submitted&kind=article')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id);
$this->getJson('/management/editorial?state=approved')->assertOk()->assertJsonPath('items.total', 0);
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->value('id');
$this->postJson('/management/editorial/notices/'.$notice.'/read')->assertOk();
$this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice)->value('read_at'));
$this->actingAsWithMfa($this->author)->postJson('/management/editorial/notices/'.$notice.'/read')->assertNotFound();
$outsider = User::factory()->create();
DB::table('portal_editorial_notifications')->insert(['edition_id' => $e->id, 'user_id' => $outsider->id, 'action' => 'submit', 'created_at' => now()]);
$this->actingAsWithMfa($outsider)->getJson('/management/editorial')->assertForbidden();
$this->getJson('/management/editorial/resources/'.$this->resource)->assertForbidden();
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
config(['portal.managers' => [$this->author->email, $this->publisher->email, $this->legal->email]]);
$this->action($this->publisher, $e, 'publish', 403);
$this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
}
public function test_all_contributors_are_excluded_from_independent_approval_and_modified_bytes_block_review(): void
{
$this->save(['description' => 'Contributo deliberado da autora.']);
$this->actingAsWithMfa($this->reviewer);
$e = app(PortalEditorialWorkflow::class)->save($this->reviewer, $this->resource, 2, ['title' => 'Edição colaborativa']);
$e = $this->action($this->reviewer, $e, 'submit');
$e = $this->action($this->legal, $e, 'start_review');
$this->action($this->author, $e, 'approve', 403);
$this->action($this->reviewer, $e, 'approve', 403);
$e = $this->action($this->legal, $e, 'approve');
$this->assertSame('approved', $e->state);
$doc = $this->document('Anexo íntegro inicial.');
$e = $this->save(['document_id' => $doc]);
$key = DB::table('documents')->where('id', $doc)->value('storage_key');
Storage::disk('local')->put($key, 'Alteração dos bytes sem actualizar os metadados.');
$this->action($this->author, $e, 'submit', 404);
$this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.fresh', false);
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
}
public function test_new_document_is_prepared_privately_without_disclosure_or_direct_publication(): void
{
$doc = $this->document('Documento novo não publicado.');
DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']);
$marker = 'SYNTHEDITORIAL'.Str::random(10);
$this->actingAsWithMfa($this->author)->post('/management/portal-services/resources', ['document_id' => $doc, 'title' => $marker, 'category' => 'guide', 'topic' => 'qa',
'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized',
'independent_review' => true, 'source_reference' => 'Fonte sintética QA, sem divulgação', 'source_url' => 'https://example.invalid/source', 'legislation' => false, 'essential' => false, 'data_classification' => 'public'])->assertSessionHasNoErrors()->assertRedirect();
$id = DB::table('portal_resources')->where('title', $marker)->value('id');
$this->assertNotNull($id);
$this->assertSame('draft', app(PortalEditorialWorkflow::class)->latest($id)->state);
$this->assertFalse((bool) DB::table('portal_resources')->where('id', $id)->value('published'));
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
$this->get('/documentos/'.$id.'/download')->assertNotFound();
$this->get('/pesquisa?q='.$marker)->assertOk()->assertInertia(fn (Assert $p) => $p->where('results.total', 0));
$this->resource = (int) $id;
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->reviewer, $e, 'start_review');
$e = $this->action($this->reviewer, $e, 'approve');
$this->get('/documentos/'.$id.'/download')->assertNotFound();
$this->action($this->publisher, $e, 'publish');
$this->get('/documentos/'.$id.'/download')->assertOk();
}
public function test_migration_refuses_an_operational_target_and_destructive_rollback_with_history(): void
{
$this->save();
$migration = require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php');
try {
$migration->down();
$this->fail('Existing editions must not be removed by rollback.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('Preserve editions', $exception->getMessage());
}
config(['database.connections.pgsql.port' => 5432]);
try {
$migration->up();
$this->fail('Operational-target schema change must be refused.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('only in isolated QA', $exception->getMessage());
} finally {
config(['database.connections.pgsql.port' => 55441]);
}
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
$this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published'));
}
public function test_missing_editorial_schema_blocks_writes_without_blocking_the_existing_dashboard(): void
{
(require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->down();
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
$this->actingAsWithMfa($this->author)->get('/dashboard')->assertOk();
$this->putJson('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Não aplicar sem estrutura.', 'image_mode' => 'none'])->assertStatus(503);
$this->postJson('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertStatus(503);
$this->assertEquals($before, DB::table('portal_resources')->where('id', $this->resource)->first());
}
public function test_review_independence_fact_is_not_a_policy_and_cannot_be_claimed_by_the_author(): void
{
config(['editorial_workflow.independent_approval.kinds' => [], 'editorial_workflow.independent_approval.resources' => []]);
$e = $this->save(['independent_review' => true]);
$this->assertFalse(app(PortalEditorialWorkflow::class)->requiresIndependent($e));
$this->assertFalse(app(PortalEditorialWorkflow::class)->payload($e)['resource']['independent_review']);
$e = $this->action($this->author, $e, 'submit');
$e = $this->action($this->author, $e, 'start_review');
$e = $this->action($this->author, $e, 'approve');
$this->action($this->author, $e, 'publish');
$this->assertFalse((bool) DB::table('portal_resources')->where('id', $this->resource)->value('independent_review'));
}
}

Some files were not shown because too many files have changed in this diff Show More