Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4541298c0 | ||
|
|
f40924f1be | ||
|
|
d5c960b62f | ||
|
|
fa69884ab0 | ||
|
|
e473542494 | ||
|
|
b3d4b7abe6 | ||
|
|
d3eeb70370 | ||
|
|
3c4c2599f7 | ||
|
|
078f770dea | ||
|
|
e9278ee446 | ||
|
|
2459d0ca37 | ||
|
|
95b33275aa | ||
|
|
a045162773 | ||
|
|
d2c72f5e13 | ||
|
|
f059c226d2 | ||
|
|
1218734eca | ||
|
|
d9f088587b | ||
|
|
b3849d850e | ||
|
|
2bad72c042 | ||
|
|
39bddad091 | ||
|
|
42b9324acb | ||
|
|
7e7bd4fde3 | ||
|
|
1263b4f6b7 | ||
|
|
2f1ef2fdfc | ||
|
|
61b4d9f1f9 | ||
|
|
1d52c3d8f4 | ||
|
|
0f79d0d54c | ||
|
|
d325b72fad | ||
|
|
8854b2928c | ||
|
|
f53a308327 | ||
|
|
723ce2a0a4 | ||
|
|
07182c7c73 | ||
|
|
bb6824d75d | ||
|
|
095280e29d | ||
|
|
e92519dd75 | ||
|
|
4988ca89dc | ||
|
|
258c8bd28b | ||
|
|
df543a4414 | ||
|
|
291a1c420f | ||
|
|
b20068d04b | ||
|
|
880e12890c |
@@ -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.');
|
||||
|
||||
@@ -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)]),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class DemographyController extends Controller
|
||||
{
|
||||
public function index(Request $request, DemographicEvidence $service): Response|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
|
||||
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
|
||||
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
|
||||
$start = (int) ($data['start_year'] ?? date('Y'));
|
||||
$end = (int) ($data['end_year'] ?? $start);
|
||||
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
|
||||
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($evidence);
|
||||
}
|
||||
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
|
||||
|
||||
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
|
||||
}
|
||||
|
||||
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
|
||||
{
|
||||
if (! Schema::hasTable('demographic_imports')) {
|
||||
return redirect('/management/demography/install');
|
||||
}
|
||||
$service->authorize($request->user());
|
||||
$data = $request->validate(['batch' => 'nullable|uuid']);
|
||||
|
||||
$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]);
|
||||
}
|
||||
|
||||
public function installation(Request $request, DemographicInstallation $service): Response
|
||||
{
|
||||
return Inertia::render('management/demography-install', ['plan' => $service->preview($request->user())]);
|
||||
}
|
||||
|
||||
public function install(Request $request, DemographicInstallation $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->apply($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function prepare(Request $request, DemographicImport $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file|mimes:xlsx|max:19531', 'publication_version' => 'required|string|max:255', 'baseline_year' => 'nullable|integer|between:1900,2100']);
|
||||
$file = $request->file('file');
|
||||
|
||||
return response()->json($service->prepare($request->user(), $file->getRealPath(), $file->getClientOriginalName(), $data['publication_version'], isset($data['baseline_year']) ? (int) $data['baseline_year'] : null));
|
||||
}
|
||||
|
||||
public function confirm(Request $request, string $id, DemographicImport $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['sha256' => 'required|string|size:64', 'mapping' => 'present|array', 'mapping.*' => 'required|uuid', 'reason' => 'required|string|min:10|max:2000']);
|
||||
|
||||
return response()->json($service->confirm($request->user(), $id, $data['sha256'], $data['mapping'], $data['reason']));
|
||||
}
|
||||
|
||||
public function transition(Request $request, string $id, DemographicImport $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['action' => 'required|in:approved,disabled', 'reason' => 'required|string|min:10|max:2000']);
|
||||
$service->transition($request->user(), $id, $data['action'], $data['reason']);
|
||||
|
||||
return response()->json(['id' => $id, 'status' => $data['action']]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
|
||||
@@ -3,31 +3,105 @@
|
||||
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)));
|
||||
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
|
||||
@@ -63,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]);
|
||||
@@ -126,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();
|
||||
@@ -142,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();
|
||||
|
||||
@@ -20,6 +20,16 @@ use Inertia\Response;
|
||||
|
||||
class InvestorPathController extends Controller
|
||||
{
|
||||
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(app(InvestorPath::class)->available(), 404);
|
||||
$request->validate(['opportunity' => 'nullable|string|max:150']);
|
||||
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
|
||||
|
||||
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
|
||||
->header('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
public function index(Request $request, InvestorPath $path): JsonResponse|Response
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +44,10 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class DemographicEvidence
|
||||
{
|
||||
public function territories(): Builder
|
||||
{
|
||||
$public = app(InvestmentGeography::class)->publicReferences();
|
||||
|
||||
return DB::table('geo_locations')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district'])
|
||||
->where(function ($q) use ($public) {
|
||||
$q->whereIn('id', (clone $public)->select('id'));
|
||||
if ($this->installed() && str_ends_with(DB::connection()->getDatabaseName(), '_test')) {
|
||||
// Macate/Vanduzi: expressly authorised QA references, never active registry changes.
|
||||
$q->orWhere(fn ($qa) => $qa->where('source_system', 'ine-demography-qa')->where('data_classification', 'public')->whereIn('parent_id', (clone $public)->select('id')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return (bool) config('demography.enabled', app()->environment('local', 'testing'))
|
||||
&& Schema::hasTable('demographic_imports') && Schema::hasTable('demographic_observations');
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
public static function sectors(): array
|
||||
{
|
||||
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
|
||||
}
|
||||
|
||||
/** @param list<string> $territories
|
||||
* @return array<string,mixed> */
|
||||
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
|
||||
{
|
||||
$result = ['state' => 'unavailable', 'start_year' => $start, 'end_year' => $end, 'sector' => $sector, 'territories' => [], 'differences' => [], 'comparisons' => [],
|
||||
'limitations' => ['População residente não mede procura efectiva ou poder de compra.', 'Idade activa não demonstra disponibilidade ou qualificação de mão-de-obra.',
|
||||
'Sem extrapolação: anos sem série oficial permanecem sem dados.', 'A demografia não altera impostos, incentivos ou probabilidades de sucesso.']];
|
||||
if (! $this->installed() || $territories === []) {
|
||||
return $result;
|
||||
}
|
||||
$locations = $this->territories()->whereIn('id', $territories)->get(['id', 'name', 'location_type']);
|
||||
$records = DB::table('demographic_observations as o')->join('demographic_imports as b', 'b.id', '=', 'o.import_id')
|
||||
->where('b.status', 'approved')->whereIn('o.geo_location_id', $locations->pluck('id'))->whereBetween('o.year', [$start, $end])
|
||||
->where('o.sex', 'all')->when($edition, fn ($q) => $q->where('b.id', $edition))
|
||||
->orderBy('o.year')->orderBy('o.import_id')->orderBy('o.id')
|
||||
->get(['o.*', 'b.source', 'b.publication_version', 'b.sha256', 'b.file_name']);
|
||||
$issuesByImport = DB::table('demographic_imports')->whereIn('id', $records->pluck('import_id')->unique()->all())
|
||||
->get(['id', DB::raw("report->'issue_counts' AS issue_counts")])->keyBy('id');
|
||||
foreach ($locations as $location) {
|
||||
$series = [];
|
||||
foreach ($records->where('geo_location_id', $location->id)->groupBy('import_id') as $importId => $rows) {
|
||||
$first = $rows->first();
|
||||
$issueCounts = json_decode($issuesByImport->get($importId)->issue_counts ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$values = $rows->filter(function ($r) use ($sector) {
|
||||
if ($r->indicator !== 'population') {
|
||||
return in_array($sector, ['health', 'general'], true);
|
||||
}
|
||||
|
||||
return $r->age_group === 'all' || in_array($sector, ['education', 'health', 'industry', 'agriculture', 'general'], true);
|
||||
})->map(fn ($r) => ['year' => $r->year, 'indicator' => $r->indicator, 'age_group' => $r->age_group, 'residence' => $r->residence,
|
||||
'value' => $r->numeric_value, 'original_value' => $r->original_value, 'unit' => $r->unit, 'data_kind' => $r->data_kind,
|
||||
'sheet' => $r->sheet, 'cell' => $r->cell, 'validation_status' => $r->validation_status])->values()->all();
|
||||
$years = array_values(array_unique(array_column($values, 'year')));
|
||||
$series[] = ['import_id' => $importId, 'source' => $first->source, 'version' => $first->publication_version, 'sha256' => $first->sha256, 'file_name' => $first->file_name,
|
||||
'values' => $values, 'missing_years' => array_values(array_diff(range($start, $end), $years)), 'issue_counts' => $issueCounts];
|
||||
}
|
||||
$result['territories'][] = ['id' => $location->id, 'name' => $location->name, 'level' => $location->location_type, 'series' => $series,
|
||||
'factors_to_assess' => match ($sector) {
|
||||
'industry' => ['Estrutura etária e evolução da população; verificar qualificações, mobilidade e emprego.'],
|
||||
'agriculture' => ['População rural e mercados locais; relacionar com os clusters documentados.'],
|
||||
'education' => ['Grupos etários da população-alvo; verificar cobertura e capacidade dos serviços.'],
|
||||
'health' => ['Estrutura etária e estatísticas vitais com unidades conhecidas; verificar cobertura dos serviços.'],
|
||||
default => ['Dimensão e evolução da população; confrontar com rendimento, acesso e condições sectoriais.'],
|
||||
}];
|
||||
}
|
||||
// Identify concurrent values without summing editions or choosing a supposedly newer one.
|
||||
foreach ($result['territories'] as $territory) {
|
||||
$byDimension = [];
|
||||
foreach ($territory['series'] as $series) {
|
||||
foreach ($series['values'] as $row) {
|
||||
$key = implode('|', [$row['year'], $row['indicator'], $row['age_group'], $row['residence'], $row['unit']]);
|
||||
$byDimension[$key][] = ['import_id' => $series['import_id'], 'value' => $row['value'], 'data_kind' => $row['data_kind']];
|
||||
}
|
||||
}
|
||||
foreach ($byDimension as $key => $values) {
|
||||
if (count($values) > 1) {
|
||||
$result['differences'][] = ['territory_id' => $territory['id'], 'dimension' => $key, 'state' => count(array_unique(array_column($values, 'value'))) === 1 ? 'matching_values' : 'divergent_values', 'values' => $values];
|
||||
}
|
||||
}
|
||||
}
|
||||
$result['state'] = $records->isEmpty() ? 'no_data' : 'available';
|
||||
foreach ($result['territories'] as $i => $left) {
|
||||
foreach (array_slice($result['territories'], $i + 1) as $right) {
|
||||
foreach ($left['series'] as $a) {
|
||||
foreach ($right['series'] as $b) {
|
||||
foreach (array_unique([$start, $end]) as $year) {
|
||||
$isTotal = fn ($r) => $r['year'] === $year && $r['indicator'] === 'population' && $r['age_group'] === 'all' && $r['residence'] === 'all';
|
||||
$x = array_values(array_filter($a['values'], $isTotal))[0] ?? null;
|
||||
$y = array_values(array_filter($b['values'], $isTotal))[0] ?? null;
|
||||
if (! $x || ! $y) {
|
||||
continue;
|
||||
}
|
||||
$comparable = $left['level'] === $right['level'] && $x['unit'] === $y['unit'] && $x['data_kind'] === $y['data_kind'] && $a['version'] === $b['version'];
|
||||
$delta = $comparable ? DB::selectOne('SELECT (?::numeric - ?::numeric)::text AS value', [$x['value'], $y['value']])->value : null;
|
||||
$result['comparisons'][] = ['year' => $year, 'left' => $left['name'], 'right' => $right['name'], 'left_value' => $x['value'], 'right_value' => $y['value'],
|
||||
'left_import' => $a['import_id'], 'right_import' => $b['import_id'], 'difference' => $delta,
|
||||
'explanation' => $comparable ? 'Diferença dos totais de população no mesmo ano, nível, tipo de dado, unidade e identificação de edição. Não mede procura efectiva.' : 'Âmbitos ou metadados distintos; não se calculou uma diferença como se fossem equivalentes.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context
|
||||
* @return array<string,mixed> */
|
||||
public function forContext(array $context): array
|
||||
{
|
||||
$fields = $context['fields'] ?? [];
|
||||
$id = ($fields['district_id'] ?? null) ?: ($fields['province_id'] ?? null);
|
||||
|
||||
// No inference from an influence area to a project's implantation territory.
|
||||
return $this->analyse($id ? [$id] : [], (int) date('Y'), (int) date('Y'), 'general');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
|
||||
$directory = $root.'/'.$sha;
|
||||
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.');
|
||||
}
|
||||
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.');
|
||||
}
|
||||
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'] ?? '')
|
||||
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
|
||||
|
||||
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,mixed>> $files */
|
||||
public function preparedSourcesReady(array $files): bool
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return $files !== [];
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function authorize(User $actor): void
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless(($actor->can('manage-portal') || $access->superuser($actor)) && ! $access->has($actor, 'auditor'), 403);
|
||||
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme MFA para gerir dados demográficos.');
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
abort_unless(app(DemographicInstallation::class)->activeTarget() && app(DemographicEvidence::class)->installed(), 403, 'Destino demográfico não instalado ou não autorizado.');
|
||||
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Conclua MFA nesta sessão para gerir dados demográficos.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function prepare(User $actor, string $file, string $name, string $version, ?int $baselineYear): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
abort_unless(is_file($file) && filesize($file) <= 20_000_000 && strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'xlsx', 422);
|
||||
$sha = CheckedFile::sha256($file);
|
||||
$existing = DB::table('demographic_imports')->where('sha256', $sha)->first();
|
||||
if ($existing) {
|
||||
return $this->preview($actor, $existing->id);
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
$directory = 'demography/'.$id;
|
||||
$disk = Storage::disk('local');
|
||||
$disk->makeDirectory($directory);
|
||||
$path = $disk->path($directory.'/original.xlsx');
|
||||
abort_unless(copy($file, $path) && CheckedFile::matches($path, $sha), 409);
|
||||
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
|
||||
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
|
||||
$output = $disk->path($directory.'/preview.json');
|
||||
$prepared = $this->preparedSource($sha, $baselineYear);
|
||||
if ($prepared !== null) {
|
||||
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
|
||||
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
|
||||
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
|
||||
} else {
|
||||
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
|
||||
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
|
||||
if ($baselineYear !== null) {
|
||||
array_push($command, '--baseline-year', (string) $baselineYear);
|
||||
}
|
||||
$process = new Process($command, base_path(), timeout: 120);
|
||||
$process->run();
|
||||
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
|
||||
}
|
||||
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
|
||||
$summary = array_diff_key($report, ['records' => true]);
|
||||
$summary['scan'] = ['result' => $scan['result'], 'engine' => $scan['engine'], 'engine_version' => $scan['engine_version'], 'sha256' => $sha];
|
||||
// The supplied publication label is provenance metadata, never an invented edition/date.
|
||||
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'file_name' => basename($name),
|
||||
'source' => $report['source'], 'publication_version' => $version, 'status' => 'prepared',
|
||||
'report' => json_encode($summary, JSON_THROW_ON_ERROR), 'preview_sha256' => CheckedFile::sha256($output),
|
||||
'prepared_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->event($id, $actor, 'prepared', 'Pré-visualização do original; sem aprovação de utilização.', ['sha256' => $sha]);
|
||||
|
||||
return $this->preview($actor, $id);
|
||||
}
|
||||
|
||||
public static function key(string $province, string $territory, string $level): string
|
||||
{
|
||||
return $province.'|'.$territory.'|'.$level;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor, string $id): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
$batch = DB::table('demographic_imports')->where('id', $id)->first();
|
||||
abort_unless($batch !== null, 404);
|
||||
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
|
||||
$locations = DB::table('geo_locations')->whereIn('location_type', ['province', 'district'])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'location_type']);
|
||||
$matches = [];
|
||||
foreach ($report['territories'] as [$province, $territory, $level]) {
|
||||
$provinces = $locations->filter(fn ($g) => $g->location_type === 'province' && $this->normalize($g->name) === $this->normalize($province));
|
||||
$candidates = $level === 'province' ? $provinces : $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->normalize($g->name) === $this->normalize($territory));
|
||||
$key = self::key($province, $territory, $level);
|
||||
if ($level === 'district') {
|
||||
$headers = $report['territory_headers'][$key] ?? [];
|
||||
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Distrito de /i', $h) === 1)) {
|
||||
$candidates = $candidates->filter(fn ($g) => ! preg_match('/^CIDADE /i', $g->name));
|
||||
}
|
||||
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Cidade (de|da) /i', $h) === 1)) {
|
||||
$candidates = $candidates->filter(fn ($g) => preg_match('/^CIDADE /i', $g->name) === 1);
|
||||
}
|
||||
$manifest = DemographicInstallation::remainingManifest();
|
||||
$hashes = array_column($manifest['files'], 'sha256');
|
||||
$alias = in_array($batch->sha256, $hashes, true) ? ($manifest['aliases'][$key] ?? null) : null;
|
||||
if ($alias !== null) {
|
||||
$candidates = $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->literal($g->name) === $this->literal($alias));
|
||||
}
|
||||
}
|
||||
$matches[] = ['key' => self::key($province, $territory, $level), 'province' => $province, 'territory' => $territory, 'level' => $level,
|
||||
'geo_location_id' => $candidates->count() === 1 ? $candidates->first()->id : null, 'state' => $candidates->count() === 1 ? 'matched' : ($candidates->isEmpty() ? 'missing' : 'ambiguous')];
|
||||
}
|
||||
|
||||
return ['id' => $id, 'file_name' => $batch->file_name, 'sha256' => $batch->sha256, 'publication_version' => $batch->publication_version,
|
||||
'status' => $batch->status, 'report' => $report, 'matches' => $matches,
|
||||
'territory_options' => $locations->values()->all(), 'events' => DB::table('demographic_events')->where('import_id', $id)->orderBy('id')->get()];
|
||||
}
|
||||
|
||||
private function normalize(string $name): string
|
||||
{
|
||||
return preg_replace('/^(cidade|distrito) (de|da) /', '', $this->literal($name));
|
||||
}
|
||||
|
||||
private function literal(string $name): string
|
||||
{
|
||||
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
|
||||
|
||||
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
|
||||
}
|
||||
|
||||
/** @param array<string,string> $mapping
|
||||
* @return array<string,mixed> */
|
||||
public function confirm(User $actor, string $id, string $sha, array $mapping, string $reason): array
|
||||
{
|
||||
$this->authorize($actor);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $sha, $mapping, $reason) {
|
||||
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($batch && hash_equals($batch->sha256, $sha), 409);
|
||||
if ($batch->status !== 'prepared') {
|
||||
return ['id' => $id, 'status' => $batch->status, 'records' => DB::table('demographic_observations')->where('import_id', $id)->count(), 'repeated' => true];
|
||||
}
|
||||
$disk = Storage::disk('local');
|
||||
$file = $disk->path('demography/'.$id.'/preview.json');
|
||||
abort_unless(is_file($file) && CheckedFile::matches($file, $batch->preview_sha256) && CheckedFile::matches($disk->path('demography/'.$id.'/original.xlsx'), $sha), 409, 'Prévia ou original alterados.');
|
||||
$report = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
$blocking = array_intersect(array_keys($report['issue_counts']), ['conflicting_value', 'duplicate_value', 'unsupported_sheet', 'unsupported_population_headers', 'reference_year_required', 'title_year_mismatch', 'invalid_numeric_or_formula']);
|
||||
abort_if($blocking !== [], 422, 'Corrija os erros estruturais antes de confirmar: '.implode(', ', $blocking));
|
||||
$plan = $this->preview($actor, $id);
|
||||
$resolved = [];
|
||||
foreach ($plan['matches'] as $match) {
|
||||
$locationId = $mapping[$match['key']] ?? $match['geo_location_id'];
|
||||
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->first();
|
||||
abort_unless($location && $location->location_type === $match['level'], 422, 'Correspondência territorial por resolver.');
|
||||
if ($match['level'] === 'district') {
|
||||
$provinceKey = self::key($match['province'], $match['province'], 'province');
|
||||
$provinceMatch = array_values(array_filter($plan['matches'], fn ($m) => $m['key'] === $provinceKey))[0] ?? null;
|
||||
abort_unless($location->parent_id === ($mapping[$provinceKey] ?? $provinceMatch['geo_location_id'] ?? null), 422, 'Distrito fora da província indicada.');
|
||||
}
|
||||
abort_if(in_array($locationId, $resolved, true), 422, 'Duas designações não podem ser fundidas na mesma localização.');
|
||||
$resolved[$match['key']] = $locationId;
|
||||
}
|
||||
$chunk = [];
|
||||
$recordCount = 0;
|
||||
foreach ($this->recordRows($report, $disk->path('demography/'.$id.'/records.ndjson')) as $row) {
|
||||
$recordCount++;
|
||||
$chunk[] = ['import_id' => $id, 'geo_location_id' => $resolved[self::key($row['province'], $row['territory'], $row['level'])],
|
||||
...array_intersect_key($row, array_flip(['year', 'indicator', 'sex', 'residence', 'age_group', 'unit', 'data_kind', 'original_value', 'numeric_value', 'sheet', 'cell'])),
|
||||
'validation_status' => $report['issues'] === [] ? 'valid' : 'warning'];
|
||||
if (count($chunk) === 500) {
|
||||
DB::table('demographic_observations')->insert($chunk);
|
||||
$chunk = [];
|
||||
}
|
||||
}
|
||||
if ($chunk !== []) {
|
||||
DB::table('demographic_observations')->insert($chunk);
|
||||
}
|
||||
abort_unless($recordCount === $report['record_count'], 409, 'O número de observações difere da prévia.');
|
||||
DB::table('demographic_imports')->where('id', $id)->update(['status' => 'imported', 'updated_at' => now()]);
|
||||
$this->event($id, $actor, 'imported', $reason, ['mapping' => $resolved, 'records' => $recordCount, 'sha256' => $sha]);
|
||||
|
||||
return ['id' => $id, 'status' => 'imported', 'records' => $recordCount, 'repeated' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $report
|
||||
* @return iterable<array<string,mixed>> */
|
||||
private function recordRows(array $report, string $path): iterable
|
||||
{
|
||||
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;
|
||||
}
|
||||
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function transition(User $actor, string $id, string $action, string $reason): void
|
||||
{
|
||||
$this->authorize($actor);
|
||||
DB::transaction(function () use ($actor, $id, $action, $reason) {
|
||||
$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]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $details */
|
||||
private function event(string $id, User $actor, string $action, string $reason, array $details): void
|
||||
{
|
||||
DB::table('demographic_events')->insert(['import_id' => $id, 'actor_id' => $actor->id, 'action' => $action, 'reason' => $reason, 'details' => json_encode($details, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\CheckedFile;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/** Owner-authorised release; schema and data still require existing session MFA. */
|
||||
final class DemographicInstallation
|
||||
{
|
||||
public const MIGRATION = '2026_11_13_100000_create_demographic_series';
|
||||
|
||||
public const MANICA_SHA256 = '0c6d8feaefaed797fa57d61f923af5a86f37ac4b16f12d7d72ae1be8087ea698';
|
||||
|
||||
public const FILES = [
|
||||
'Manica.xlsx' => ['sha256' => self::MANICA_SHA256, 'records' => 71604],
|
||||
'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620],
|
||||
'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482],
|
||||
];
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function remainingManifest(): array
|
||||
{
|
||||
return json_decode(CheckedFile::read(database_path('data/demography-remaining-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function zambeziaManifest(): array
|
||||
{
|
||||
return json_decode(CheckedFile::read(database_path('data/demography-zambezia-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function publicDistrictRefs(): array
|
||||
{
|
||||
$refs = [self::MANICA_SHA256.':Macate', self::MANICA_SHA256.':Vanduzi'];
|
||||
foreach ([self::remainingManifest(), self::zambeziaManifest()] as $manifest) {
|
||||
foreach ($manifest['new_districts'] as $province => $names) {
|
||||
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
|
||||
foreach ($names as $name) { $refs[] = $source.':'.$name; }
|
||||
}
|
||||
}
|
||||
return $refs;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function files(): array
|
||||
{
|
||||
return ['database/data/demography-zambezia-20261113.json', 'database/data/demography-remaining-20261113.json', 'app/Services/DemographicInstallation.php', 'app/Services/DemographicImport.php', 'app/Services/DemographicEvidence.php',
|
||||
'app/Services/InvestmentGeography.php', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/DemographyController.php',
|
||||
'database/migrations/'.self::MIGRATION.'.php', 'config/demography.php', 'routes/demography.php', 'routes/web.php', 'tools/demography_workbook.py',
|
||||
'resources/js/components/demographic-context.tsx', 'resources/js/components/demographic-evidence.tsx', 'resources/js/components/simulation-form.tsx',
|
||||
'resources/js/components/structure-geography.tsx', 'resources/js/pages/investment/demography.tsx', 'resources/js/pages/investment/catalogue.tsx',
|
||||
'resources/js/pages/investment/detail.tsx', 'resources/js/pages/management/demography.tsx', 'resources/js/pages/management/demography-install.tsx',
|
||||
'resources/js/pages/management/shared.tsx'];
|
||||
}
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
$files = [];
|
||||
foreach ($this->files() as $path) {
|
||||
$files[$path] = CheckedFile::sha256(base_path($path));
|
||||
}
|
||||
|
||||
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function activeTarget(): bool
|
||||
{
|
||||
$c = config('database.connections.pgsql');
|
||||
$homologation = config('app.url') === 'https://teste-investidor.tempo.co.mz'
|
||||
&& config('demography.enabled', false) && $c['database'] === 'teste_investidor';
|
||||
if (app()->environment('testing') || config('database.default') !== 'pgsql' || ! empty($c['url'])
|
||||
|| ! in_array($c['host'], ['127.0.0.1', 'localhost'], true) || (int) $c['port'] !== 5432
|
||||
|| ($c['database'] !== 'investemoz' && ! $homologation)) {
|
||||
return false;
|
||||
}
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
|
||||
return $target->database === $c['database'] && (int) $target->port === 5432;
|
||||
}
|
||||
|
||||
public function schemaAllowed(): bool
|
||||
{
|
||||
if (IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
return true;
|
||||
}
|
||||
$actor = auth()->user();
|
||||
if (! config('demography.authenticated_installation', false) || ! $actor || ! $this->activeTarget()) {
|
||||
return false;
|
||||
}
|
||||
app(PortalProfileInstallation::class)->authorize($actor, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function authorize(User $actor, bool $write): void
|
||||
{
|
||||
app(PortalProfileInstallation::class)->authorize($actor, $write);
|
||||
abort_unless($this->activeTarget() || IsolatedQaDatabase::allowsSchemaChanges(), 403, 'Destino não autorizado para esta entrega.');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor): array
|
||||
{
|
||||
$this->authorize($actor, false);
|
||||
$proofFile = storage_path('app/private/demography-deployment/readiness.json');
|
||||
$proof = is_file($proofFile) ? json_decode(CheckedFile::read($proofFile), true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$code = $this->fingerprint();
|
||||
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$qa = IsolatedQaDatabase::allowsSchemaChanges();
|
||||
$ready = $qa || (($proof['code_sha256'] ?? '') === $code && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
|
||||
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false) && ($proof['target']['database'] ?? '') === $target['database']
|
||||
&& (int) ($proof['target']['port'] ?? 0) === (int) $target['port'] && ($proof['decision'] ?? '') !== ''
|
||||
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time());
|
||||
if (! $qa) {
|
||||
foreach (['dump', 'files'] as $kind) {
|
||||
$file = $proof[$kind.'_path'] ?? '';
|
||||
$ready = $ready && is_file($file) && CheckedFile::matches($file, $proof[$kind.'_sha256'] ?? '');
|
||||
}
|
||||
$ready = $ready && CheckedFile::matches(base_path('.env'), $proof['environment_sha256'] ?? '')
|
||||
&& is_file(public_path('build/manifest.json')) && CheckedFile::matches(public_path('build/manifest.json'), $proof['release_manifest_sha256'] ?? '');
|
||||
foreach ($proof['release_files'] ?? [] as $relative => $sha) {
|
||||
$file = public_path('build/'.$relative);
|
||||
$ready = $ready && ! str_contains($relative, '..') && ! str_contains($relative, ':') && is_file($file) && CheckedFile::matches($file, $sha);
|
||||
}
|
||||
}
|
||||
$tables = array_map(fn ($t) => Schema::hasTable($t), ['demographic_imports', 'demographic_observations', 'demographic_events']);
|
||||
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
|
||||
$consistent = ($ledger && ! in_array(false, $tables, true)) || (! $ledger && ! in_array(true, $tables, true));
|
||||
$preparedFiles = self::FILES + self::remainingManifest()['files'] + self::zambeziaManifest()['files'];
|
||||
$runtimeReady = app(DemographicImport::class)->preparedSourcesReady($preparedFiles);
|
||||
try {
|
||||
if (! $runtimeReady && function_exists('proc_open')) {
|
||||
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
|
||||
$runtime->run();
|
||||
$runtimeReady = $runtime->isSuccessful();
|
||||
}
|
||||
} catch (ExceptionInterface) {
|
||||
$runtimeReady = false;
|
||||
}
|
||||
$mode = $ledger ? 'remaining' : 'initial';
|
||||
$manifest = self::remainingManifest();
|
||||
if ($ledger && DB::table('demographic_imports')->whereIn('sha256', array_column($manifest['files'], 'sha256'))->where('status', 'approved')->count() === count($manifest['files'])) { $mode = 'zambezia'; $manifest = self::zambeziaManifest(); }
|
||||
$files = $mode !== 'initial' ? $manifest['files'] : self::FILES;
|
||||
$already = $ledger ? DB::table('demographic_imports')->whereIn('sha256', array_column($files, 'sha256'))->where('status', 'approved')->pluck('sha256')->all() : [];
|
||||
$pending = array_filter($files, fn ($f) => ! in_array($f['sha256'], $already, true));
|
||||
$state = ['mode' => $mode, 'pending_count' => count($pending), 'aliases' => $mode !== 'initial' ? $manifest['aliases'] : [], 'alias_sources' => $mode !== 'initial' ? $manifest['sources'] : [], 'missing_province_source' => $manifest['missing_province_source'], 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
|
||||
'migration' => self::MIGRATION, 'code_sha256' => $code, 'backup_verified' => $ready, 'consistent' => $consistent,
|
||||
'ready' => $ready && $consistent && $runtimeReady && count($pending) > 0, 'runtime_ready' => $runtimeReady, 'installed' => $ledger, 'files' => $files, 'new_districts' => $mode !== 'initial' ? array_merge(...array_values($manifest['new_districts'])) : ['Macate', 'Vanduzi'],
|
||||
'mfa_confirmed' => app(PortalProfileAccess::class)->mfa($actor),
|
||||
'notice' => ($runtimeReady ? '' : 'O interpretador do importador está indisponível; a execução permanece bloqueada. ').'Preservar seis advertências de Manica/2031, unidades ausentes e versões estatísticas. A instalação não corrige os originais nem concede permissões.'];
|
||||
|
||||
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
|
||||
}
|
||||
|
||||
/** @param list<string> $preservedHashes
|
||||
* @return array<string,array{count:int,sha256:string}> */
|
||||
private function protectedState(array $preservedHashes = []): array
|
||||
{
|
||||
$state = [];
|
||||
foreach (['desk_cases', 'simulation_form_drafts', 'funding_programmes', 'funding_programme_versions', 'funding_programme_interests',
|
||||
'labour_demands', 'training_capacities', 'opportunities', 'investment_dossiers', 'projects', 'economic_clusters'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
|
||||
$state[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode("\n", array_map(fn ($r) => $r->row, $rows)))];
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('demographic_imports')) {
|
||||
$ids = DB::table('demographic_imports')->whereIn('sha256', $preservedHashes ?: array_column(self::FILES, 'sha256'))->pluck('id');
|
||||
foreach (['demographic_imports' => 'id', 'demographic_observations' => 'import_id', 'demographic_events' => 'import_id'] as $table => $key) {
|
||||
$digest = hash_init('sha256');
|
||||
$count = 0;
|
||||
DB::table($table)->whereIn($key, $ids)->chunkById(500, function ($rows) use ($digest, &$count) {
|
||||
foreach ($rows as $row) {
|
||||
if ($count > 0) { hash_update($digest, "\n"); }
|
||||
hash_update($digest, json_encode($row, JSON_THROW_ON_ERROR));
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
$state['accepted_'.$table] = ['count' => $count, 'sha256' => hash_final($digest)];
|
||||
}
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function territories(User $actor): void
|
||||
{
|
||||
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', ['manica'])->get(['id']);
|
||||
abort_unless($province->count() === 1, 422, 'Província de Manica ambígua ou indisponível.');
|
||||
foreach (['Macate', 'Vanduzi'] as $name) {
|
||||
$matches = DB::table('geo_locations')->where('parent_id', $province->first()->id)->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [strtolower($name)])->get();
|
||||
if ($matches->isNotEmpty()) {
|
||||
abort_unless($matches->count() === 1 && $matches->first()->deleted_at === null, 409, 'Cadastro territorial divergente; não restaurar nem fundir automaticamente.');
|
||||
|
||||
continue;
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $province->first()->id, 'name' => $name, 'location_type' => 'district',
|
||||
'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => self::MANICA_SHA256.':'.$name,
|
||||
'data_classification' => 'public', 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name,
|
||||
'source_sha256' => self::MANICA_SHA256, 'decision' => 'Autorização expressa do proprietário em 13/11/2026; destino conferido pelo instalador.',
|
||||
'scope' => 'Identificação territorial da série INE; sem limites geográficos ou direitos territoriais presumidos.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $manifest */
|
||||
private function remainingTerritories(User $actor, array $manifest): void
|
||||
{
|
||||
foreach ($manifest['new_districts'] as $province => $names) {
|
||||
$parents = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', [mb_strtolower($province)])->pluck('id');
|
||||
abort_unless($parents->count() === 1, 409);
|
||||
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
|
||||
foreach ($names as $name) {
|
||||
$existing = DB::table('geo_locations')->where('parent_id', $parents[0])->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [mb_strtolower($name)])->get();
|
||||
if ($existing->isNotEmpty()) { abort_unless($existing->count() === 1 && $existing[0]->deleted_at === null, 409); continue; }
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parents[0], 'name' => $name, 'location_type' => 'district', 'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => $source.':'.$name, 'data_classification' => 'public', 'version_no' => 1]);
|
||||
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name, 'source_sha256' => $source, 'basis' => $manifest['authority']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function apply(User $actor, array $input): array
|
||||
{
|
||||
$this->authorize($actor, true);
|
||||
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid',
|
||||
'mode' => 'nullable|in:initial,remaining,zambezia', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
||||
$mode = $data['mode'] ?? 'initial';
|
||||
$files = $mode === 'initial' ? self::FILES : ($mode === 'zambezia' ? self::zambeziaManifest()['files'] : self::remainingManifest()['files']);
|
||||
$preservedHashes = $mode === 'zambezia' ? array_column([...self::FILES, ...self::remainingManifest()['files']], 'sha256') : array_column(self::FILES, 'sha256');
|
||||
$action = $mode === 'zambezia' ? 'extend_zambezia' : ($mode === 'remaining' ? 'extend_provinces' : 'install_and_import');
|
||||
$payload = hash('sha256', json_encode($mode === 'initial' ? [$actor->id, $data['reason']] : [$actor->id, $data['reason'], $mode, $files], JSON_THROW_ON_ERROR));
|
||||
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
|
||||
if ($existing) {
|
||||
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
|
||||
|
||||
return [...$event['result'], 'repeated' => true];
|
||||
}
|
||||
$plan = $this->preview($actor);
|
||||
abort_unless($plan['ready'] && $plan['mode'] === $mode && hash_equals($plan['hash'], $data['hash']), 409, 'Consulte a prévia actual; código, destino ou provas mudaram.');
|
||||
set_time_limit(300);
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $payload, $mode, $files, $action, $preservedHashes) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(131126, 100000)');
|
||||
// Recheck after the lock as another request may have completed while we waited.
|
||||
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
|
||||
if ($existing) {
|
||||
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
|
||||
|
||||
return [...$event['result'], 'repeated' => true];
|
||||
}
|
||||
$before = $this->protectedState($preservedHashes);
|
||||
$previous = config('demography.authenticated_installation');
|
||||
config(['demography.authenticated_installation' => true]);
|
||||
try {
|
||||
if ($mode === 'initial') {
|
||||
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha ao instalar a estrutura demográfica.');
|
||||
} else { abort_unless(Schema::hasTable('demographic_observations'), 409); }
|
||||
} finally {
|
||||
config(['demography.authenticated_installation' => $previous]);
|
||||
}
|
||||
if ($mode === 'initial') { $this->territories($actor); } else { $this->remainingTerritories($actor, $mode === 'zambezia' ? self::zambeziaManifest() : self::remainingManifest()); }
|
||||
$receipts = [];
|
||||
$directory = config('demography.source_directory', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias');
|
||||
$service = app(DemographicImport::class);
|
||||
foreach ($files as $name => $expected) {
|
||||
$old = DB::table('demographic_imports')->where('sha256', $expected['sha256'])->first();
|
||||
if ($old && $old->status === 'approved') {
|
||||
$count = DB::table('demographic_observations')->where('import_id', $old->id)->count();
|
||||
abort_unless($count === $expected['records'], 409);
|
||||
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $old->id, 'records' => $count, 'preserved' => true];
|
||||
continue;
|
||||
}
|
||||
$file = $directory.'/'.$name;
|
||||
abort_unless(is_file($file) && CheckedFile::matches($file, $expected['sha256']), 409, 'O exemplar aprovado mudou: '.$name);
|
||||
$plan = $service->prepare($actor, $file, $name, 'Exemplar INE fornecido pela Tempo; edição não indicada; SHA256 identifica a versão', $name === 'Dados demograficos.xlsx' ? 2017 : null);
|
||||
$receipt = $service->confirm($actor, $plan['id'], $expected['sha256'], [], $data['reason']);
|
||||
abort_unless($receipt['records'] === $expected['records'], 409, 'Contagem divergente da homologação: '.$name);
|
||||
$service->transition($actor, $plan['id'], 'approved', $data['reason']);
|
||||
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $plan['id'], 'records' => $receipt['records']];
|
||||
}
|
||||
abort_unless($before === ($mode === 'initial' ? array_filter($this->protectedState($preservedHashes), fn ($key) => ! str_starts_with($key, 'accepted_'), ARRAY_FILTER_USE_KEY) : $this->protectedState($preservedHashes)), 409, 'Estado protegido alterado; instalação revertida.');
|
||||
$result = ['installed' => true, 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
|
||||
'imports' => $receipts, 'protected_state' => $before, 'record_count' => array_sum(array_column($receipts, 'records')), 'at' => now()->toIso8601String()];
|
||||
DB::table('audit_events')->insert(['source_system' => 'demography-installation', 'entity_type' => 'demographic_series',
|
||||
'actor_user_id' => $actor->id, 'action' => $action, 'correlation_id' => $data['operation_key'], 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['payload_sha256' => $payload, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -138,6 +138,10 @@ final class InvestmentGeography
|
||||
$query->where(function ($workbook) {
|
||||
$workbook->where('source_system', 'cartografia-workbook')
|
||||
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%');
|
||||
})->orWhere(function ($demography) {
|
||||
// Owner, 13/11/2026: only these source-backed additions are authorised.
|
||||
$demography->where('source_system', 'ine-demography')->where('location_type', 'district')
|
||||
->whereIn('external_id', DemographicInstallation::publicDistrictRefs());
|
||||
})->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,11 @@ final class InvestmentPreparationWorkspace
|
||||
{
|
||||
$objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']];
|
||||
$evidence = [];
|
||||
$demography = app(DemographicEvidence::class)->forContext($context);
|
||||
if ($demography['state'] === 'available') {
|
||||
$evidence['demography'] = ['key' => 'demography', 'label' => 'Contexto demográfico INE', 'current' => $demography,
|
||||
'notice' => 'Evidência para análise SWOT; não produz conclusões automáticas. Consulte ano, edição e limitações.'];
|
||||
}
|
||||
foreach ($context['sector_references'] ?? [] as $reference) {
|
||||
$current = hash_equals((SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? ''), $reference['source_sha256'] ?? '') ? $reference : ['unavailable' => true, 'source_sha256' => (SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? '')];
|
||||
$evidence['sector-resource:'.$reference['key']] = ['key' => 'sector-resource:'.$reference['key'], 'label' => $reference['title'], 'consulted' => $reference, 'current' => $current, 'notice' => 'Referência sectorial e de cadeia de valor; não certifica direitos, licenças nem disponibilidade de projecto.'];
|
||||
@@ -275,6 +280,24 @@ final class InvestmentPreparationWorkspace
|
||||
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId);
|
||||
}
|
||||
|
||||
/** Reuses the authorised workspace and its preserved exports; never creates an export or a review.
|
||||
* @return array<string,mixed> */
|
||||
public function reportSummary(User $actor, string $id, ?string $opportunity): array
|
||||
{
|
||||
$view = $this->view($actor, $id, $opportunity);
|
||||
$query = $view['opportunity'] ? '?opportunity='.urlencode($view['opportunity']) : '';
|
||||
$base = '/investimentos/'.$id.'/preparacao';
|
||||
|
||||
return ['case_id' => $id, 'opportunity' => $view['opportunity'], 'version' => $view['version'],
|
||||
'context_revision' => $view['context']['revision'], 'href' => $base.$query, 'api_state' => $view['api_state'],
|
||||
'api_issues' => $view['api_issues'], 'external' => $view['apiex']['external'],
|
||||
'exports' => array_map(fn ($export) => ['id' => $export['id'], 'at' => $export['at'],
|
||||
'context_revision' => $export['context_revision'], 'fingerprint' => $export['fingerprint'],
|
||||
'historical' => $export['fingerprint'] !== $view['api_fingerprint'],
|
||||
'href' => $base.'/exportar/'.$export['id'].$query], $this->rows($view['apiex']['exports'])),
|
||||
'swot' => $view['swot'], 'swot_review_required' => $view['swot_review_required']];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function taskKeys(User $actor, string $id, string $opportunity): array
|
||||
{
|
||||
|
||||
@@ -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'] ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
|
||||
use App\Models\User;
|
||||
use App\Support\StructuredData;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -171,6 +172,17 @@ final class InvestorPath
|
||||
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
|
||||
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
|
||||
'results' => $results, 'requests' => $requests, 'documents' => $documents,
|
||||
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
|
||||
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
|
||||
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
|
||||
'href' => '/investidor/terra-duat/'.$landCase->id.'/imprimir'] : null,
|
||||
'preparation_report' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() && (count($allChoices) < 2 || $opportunity !== null)
|
||||
? app(InvestmentPreparationWorkspace::class)->reportSummary($actor, $id, $opportunity) : null,
|
||||
'company_reports' => ($owner || $manager) ? array_filter([
|
||||
'comparison' => isset($payload['company_comparison']) ? Arr::only($payload['company_comparison'], ['revision', 'at']) : null,
|
||||
'preparation' => isset($payload['company_preparation']) ? Arr::only($payload['company_preparation'], ['revision', 'at']) : null,
|
||||
'guides' => array_map(fn ($guide) => ['revision' => $guide['revision'], 'at' => $guide['at'], 'slug' => $guide['guide']['slug'], 'title' => $guide['guide']['title'] ?? $guide['guide']['slug']], $payload['investment_guides'] ?? []),
|
||||
]) : [],
|
||||
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
|
||||
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
|
||||
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.'¬ice_version='.$version,
|
||||
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,11 +142,15 @@ 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));
|
||||
@@ -92,7 +165,12 @@ final class SearchTerms
|
||||
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);
|
||||
@@ -107,6 +185,12 @@ final class SearchTerms
|
||||
}
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
|
||||
}
|
||||
|
||||
$this->requireMatch($request, $draft);
|
||||
$associatedContext = json_decode($economic['context_applicability_snapshot'] ?? '', true);
|
||||
$sourceSnapshot['association_opportunity'] = $economic['context_opportunity'] ?? $associatedContext['consumer_opportunity'] ?? null;
|
||||
$sourceSnapshot['association_context_revision'] = $associatedContext['context_revision'] ?? null;
|
||||
$sources[] = $sourceSnapshot;
|
||||
$economic['simulation_sources'] = $sources;
|
||||
$economic['reuse_simulation'] = 'yes';
|
||||
@@ -223,6 +226,61 @@ final class SimulationFormDrafts
|
||||
});
|
||||
}
|
||||
|
||||
/** Metadata only, from the draft owned by this dossier's titular; no broader dossier ACL grants financial access.
|
||||
* @return list<array<string,mixed>> */
|
||||
public function dossierReportSources(User $actor, string $caseId, ?string $opportunity): array
|
||||
{
|
||||
$case = app(VirtualDesk::class)->case($actor, $caseId);
|
||||
if ((int) $case->user_id !== $actor->id || ! $case->simulation_id) { return []; }
|
||||
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
|
||||
|
||||
return DB::transaction(function () use ($actor, $case, $opportunity): array {
|
||||
$this->setContext($actor->id, null);
|
||||
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->first();
|
||||
if (! $draft) { return []; }
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$sources = [];
|
||||
$records = $economic['simulation_sources'] ?? [];
|
||||
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
|
||||
foreach ($records as $source) {
|
||||
if (! is_array($source) || ! Str::isUuid($source['simulation_id'] ?? '') || ! Str::isUuid($source['report_id'] ?? '')) { continue; }
|
||||
if (isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity) { continue; }
|
||||
$sources[] = [...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'source_updated_at', 'consented_at'])),
|
||||
'opportunity' => $source['association_opportunity'] ?? null, 'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'),
|
||||
'href' => '/investimentos/'.$case->id.'/relatorios/fiscais/'.$source['report_id'].($opportunity ? '?opportunity='.urlencode($opportunity) : '')];
|
||||
}
|
||||
|
||||
return $sources;
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the already preserved copy, including a source originally calculated anonymously.
|
||||
* @return array<string,mixed> */
|
||||
public function dossierReport(User $actor, string $caseId, string $reportId, ?string $opportunity): array
|
||||
{
|
||||
$case = app(VirtualDesk::class)->case($actor, $caseId);
|
||||
abort_unless((int) $case->user_id === $actor->id && $case->simulation_id, 403);
|
||||
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
|
||||
|
||||
return DB::transaction(function () use ($actor, $case, $reportId, $opportunity): array {
|
||||
$this->setContext($actor->id, null);
|
||||
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->firstOrFail();
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$records = $economic['simulation_sources'] ?? [];
|
||||
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
|
||||
$source = null;
|
||||
foreach ($records as $item) {
|
||||
if (is_array($item) && ($item['report_id'] ?? null) === $reportId) { $source = $item; break; }
|
||||
}
|
||||
abort_unless(is_array($source) && is_array($source['report_snapshot'] ?? null), 404, 'Cópia preservada indisponível; original não substituído.');
|
||||
abort_if(isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity, 404);
|
||||
|
||||
return ['case_id' => $case->id, 'opportunity' => $source['association_opportunity'] ?? null,
|
||||
'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'), 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
|
||||
...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'report_snapshot', 'consented_at']))];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
|
||||
public function claimPreview(Request $request): ?array
|
||||
{
|
||||
|
||||
@@ -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']];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Remote activation is explicit; local/QA behaviour remains unchanged.
|
||||
'enabled' => env('DEMOGRAPHY_ENABLED', env('APP_URL') !== 'https://teste-investidor.tempo.co.mz'
|
||||
&& in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)),
|
||||
'source_directory' => env('DEMOGRAPHY_SOURCE_DIRECTORY', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias'),
|
||||
'prepared_directory' => env('DEMOGRAPHY_PREPARED_DIRECTORY'),
|
||||
// An explicit interpreter avoids Windows Store execution aliases in web workers.
|
||||
'python' => env('DEMOGRAPHY_PYTHON', 'python'),
|
||||
];
|
||||
@@ -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,120 @@
|
||||
{
|
||||
"files": {
|
||||
"Cabo Delgado.xlsx": {
|
||||
"sha256": "419ebf09f26055a2d46dac7027143835ee2e2dc8a91e3b1f498c5b459566ce16",
|
||||
"records": 92808,
|
||||
"province": "Cabo Delgado",
|
||||
"issues": {
|
||||
"missing_value": 6336
|
||||
}
|
||||
},
|
||||
"Gaza.xlsx": {
|
||||
"sha256": "b66ccde3478a74a854e8e2c4d51209401d844a15a701b16267e005fa44b70dc4",
|
||||
"records": 82618,
|
||||
"province": "Gaza",
|
||||
"issues": {
|
||||
"age_total_mismatch": 4,
|
||||
"missing_value": 2,
|
||||
"residence_total_mismatch": 4,
|
||||
"sex_total_mismatch": 1
|
||||
}
|
||||
},
|
||||
"Maputo Provincia.xlsx": {
|
||||
"sha256": "1bf2921ef43d18817878a6d05e487ce5b107c14aa149ad17bedae6f1c652430e",
|
||||
"records": 49504,
|
||||
"province": "Maputo Província",
|
||||
"issues": {
|
||||
"cached_formula_value": 42972,
|
||||
"missing_value": 68
|
||||
}
|
||||
},
|
||||
"Maputo Cidade.xlsx": {
|
||||
"sha256": "ba72c0b64200e5bdb4b1dda60a0a974bc7ce7c6d28d37a490735437261f1e513",
|
||||
"records": 44064,
|
||||
"province": "Maputo Cidade",
|
||||
"issues": {}
|
||||
},
|
||||
"Nampula.xlsx": {
|
||||
"sha256": "cc960b06d7dfd0cb3f9a16edd94ff8db0451e8f3e24f3ad8442d03f7a053ac3b",
|
||||
"records": 131400,
|
||||
"province": "Nampula",
|
||||
"issues": {
|
||||
"missing_value": 792
|
||||
}
|
||||
},
|
||||
"Niassa.xlsx": {
|
||||
"sha256": "3116278d32de729058c7133ccc8204b42f14a0f8ba2b8d0aa8be46c9ff08d160",
|
||||
"records": 93632,
|
||||
"province": "Niassa",
|
||||
"issues": {
|
||||
"age_total_mismatch": 3,
|
||||
"cached_formula_value": 12,
|
||||
"district_total_mismatch": 2,
|
||||
"missing_value": 4,
|
||||
"residence_total_mismatch": 1
|
||||
}
|
||||
},
|
||||
"Sofala.xlsx": {
|
||||
"sha256": "4eaf5ef5e332ed50d5739330e14d78e2ead2551f407de7e31e0d1a96a76c956e",
|
||||
"records": 77108,
|
||||
"province": "Sofala",
|
||||
"issues": {
|
||||
"age_total_mismatch": 6,
|
||||
"missing_value": 4,
|
||||
"residence_total_mismatch": 6
|
||||
}
|
||||
},
|
||||
"Tete.xlsx": {
|
||||
"sha256": "4bbd7856107c4a3716b533caf7968a691deab4662e42689a7c529b58b80e2d58",
|
||||
"records": 67932,
|
||||
"province": "Tete",
|
||||
"issues": {
|
||||
"missing_value": 20196
|
||||
}
|
||||
}
|
||||
},
|
||||
"new_districts": {
|
||||
"Gaza": [
|
||||
"Chongoene",
|
||||
"Limpopo",
|
||||
"Mapai"
|
||||
],
|
||||
"Nampula": [
|
||||
"Larde",
|
||||
"Liupo"
|
||||
],
|
||||
"Niassa": [
|
||||
"Chimbonila"
|
||||
],
|
||||
"Tete": [
|
||||
"Doa",
|
||||
"Marara"
|
||||
]
|
||||
},
|
||||
"aliases": {
|
||||
"Cabo Delgado|Metuge|district": "PEMBA - METUGE",
|
||||
"Gaza|Mandlakaze|district": "MANDLACAZE",
|
||||
"Nampula|Erati|district": "NAMAPA-ERATI",
|
||||
"Nampula|Ilha de Mocambique|district": "CIDADE ILHA DE MOCAMBIQUE",
|
||||
"Nampula|Nacala-a-Velha|district": "NACALA-VELHA",
|
||||
"Nampula|Rapale|district": "NAMPULA",
|
||||
"Maputo Cidade|KaMphumu|district": "DISTRITO URBANO 1",
|
||||
"Maputo Cidade|Nlhamankulu|district": "DISTRITO URBANO 2",
|
||||
"Maputo Cidade|KaMaxakeni|district": "DISTRITO URBANO 3",
|
||||
"Maputo Cidade|KaMavota|district": "DISTRITO URBANO 4",
|
||||
"Maputo Cidade|KaMubukwana|district": "DISTRITO URBANO 5",
|
||||
"Maputo Cidade|KaTembe|district": "DISTRITO DE CATEMBE",
|
||||
"Maputo Cidade|KaNyaka|district": "DISTRITO DE INHACA",
|
||||
"Nampula|Nampula|district": "CIDADE DE NAMPULA",
|
||||
"Niassa|Lichinga|district": "CIDADE DE LICHINGA",
|
||||
"Gaza|Xai-Xai|district": "CIDADE DE XAI-XAI"
|
||||
},
|
||||
"authority": "Pedido de instalar restantes províncias e autorização expressa de oito distritos,13/11/2026. Correspondências históricas apresentadas na prévia autenticada,sem renomear nem fundir IDs.",
|
||||
"sources": [
|
||||
"https://archive.gazettes.africa/archive/mz/2013/mz-government-gazette-series-i-dated-2013-12-18-no-101.pdf",
|
||||
"https://international.ipums.org/international-action/variables/MZ2017A_0436",
|
||||
"https://archive.gazettes.africa/archive/mz/2020/mz-government-gazette-series-i-dated-2020-05-04-no-83.pdf"
|
||||
],
|
||||
"missing_province_source": "Zambézia",
|
||||
"city_mapping_decision": "Proprietário confirmou expressamente em13/11/2026 a ligação destes três rótulos às respectivas cidades,preservando Rapale,Chimbonila,Limpopo separados."
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"files": {
|
||||
"Zambezia.xlsx": {
|
||||
"sha256": "8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7",
|
||||
"records": 126684,
|
||||
"province": "Zambezia",
|
||||
"issues": {}
|
||||
}
|
||||
},
|
||||
"new_districts": {
|
||||
"Zambezia": [
|
||||
"Derre",
|
||||
"Luabo",
|
||||
"Mocubela",
|
||||
"Molumbo",
|
||||
"Mulevala"
|
||||
]
|
||||
},
|
||||
"aliases": {},
|
||||
"sources": [],
|
||||
"authority": "Proprietário autorizou expressamente Derre,Luabo,Mocubela,Molumbo e Mulevala e a incorporação de Zambezia.xlsx em13/11/2026,sem códigos oficiais ou geometria inferidos.",
|
||||
"missing_province_source": null
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Services\DemographicInstallation;
|
||||
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 (! app(DemographicInstallation::class)->schemaAllowed()) {
|
||||
throw new RuntimeException('Demografia: instalação exige QA isolado ou a acção autenticada autorizada.');
|
||||
}
|
||||
Schema::create('demographic_imports', function (Blueprint $t) {
|
||||
$t->uuid('id')->primary();
|
||||
$t->char('sha256', 64)->unique();
|
||||
$t->string('file_name');
|
||||
$t->string('source');
|
||||
$t->string('publication_version');
|
||||
$t->string('status')->default('prepared');
|
||||
$t->jsonb('report');
|
||||
$t->char('preview_sha256', 64);
|
||||
$t->foreignId('prepared_by')->constrained('users')->restrictOnDelete();
|
||||
$t->timestampsTz();
|
||||
});
|
||||
Schema::create('demographic_observations', function (Blueprint $t) {
|
||||
$t->bigIncrements('id');
|
||||
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
|
||||
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
|
||||
$t->unsignedSmallInteger('year');
|
||||
$t->string('indicator', 80);
|
||||
$t->string('sex', 10);
|
||||
$t->string('residence', 10);
|
||||
$t->string('age_group', 30);
|
||||
$t->string('unit', 80);
|
||||
$t->string('data_kind', 20);
|
||||
$t->string('original_value', 100);
|
||||
$t->text('numeric_value'); // Exact decimal text, including Excel storage precision.
|
||||
$t->string('sheet');
|
||||
$t->string('cell', 20);
|
||||
$t->string('validation_status', 20);
|
||||
$t->unique(['import_id', 'geo_location_id', 'year', 'indicator', 'sex', 'residence', 'age_group'], 'demography_dimension_unique');
|
||||
$t->index(['geo_location_id', 'year', 'indicator']);
|
||||
});
|
||||
Schema::create('demographic_events', function (Blueprint $t) {
|
||||
$t->bigIncrements('id');
|
||||
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
|
||||
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$t->string('action', 30);
|
||||
$t->text('reason');
|
||||
$t->jsonb('details');
|
||||
$t->timestampTz('created_at')->useCurrent();
|
||||
});
|
||||
DB::statement("ALTER TABLE demographic_imports ADD CONSTRAINT demographic_import_state CHECK (status IN ('prepared','imported','approved','disabled'))");
|
||||
DB::statement("ALTER TABLE demographic_observations ADD CONSTRAINT demographic_observation_state CHECK (validation_status IN ('valid','warning'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('demographic_imports')->exists()) {
|
||||
throw new RuntimeException('Preserve originais e histórico demográfico antes de reverter.');
|
||||
}
|
||||
Schema::dropIfExists('demographic_events');
|
||||
Schema::dropIfExists('demographic_observations');
|
||||
Schema::dropIfExists('demographic_imports');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+39
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -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,224 @@
|
||||
## 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.
|
||||
|
||||
A revisão `4988ca89dc6dddbde4efdada24546767ffcdd411` permite consumir preparações CLI privadas, vinculadas ao SHA256 do original, ano de referência, versão do preparador, prévia e fluxo NDJSON. Conserva a cópia do original, análise antimalware actual, correspondência territorial, confirmação com MFA, gravação por blocos, verificação de contagens e histórico. O CLI existente ganhou a emissão do recibo; não recalcula fórmulas nem altera estatísticas. Sem preparação válida e sem suporte a processos, a interface informa o impedimento, em vez de provocar 500. Não foi alterada a configuração de segurança do PHP.
|
||||
|
||||
Doze originais foram preparados fora do servidor web e conferidos: 920456 observações esperadas, ainda privadas e não importadas. Passaram dois testes/20 asserções com `proc_open` desactivado, 14 testes/52 asserções dos consumidores, oito testes do preparador e análise PHP. Delta e reversão foram conferidos; pacote `outputs/entrega-demografia-cli-20261113.zip`, SHA256 `4b3be263f34d6b9675b77f1ad25534f25da927d4046f57798644dce9fde24244`, preservando os pacotes anteriores.
|
||||
|
||||
**Instalado e verificado:** revisão confirmada por HTTPS às 14:06:11 Africa/Johannesburg. A verificação privada pelo socket PHP-FPM existente confirmou às 14:05:27: SAPI `fpm-fcgi`, processos continuam bloqueados, preparações disponíveis, antimalware disponível e hashes de código/configuração correctos. Não criou actor/sessão, endpoint HTTP público, permissões, migrações ou importações. Provas `outputs/demography-20261113/homologacao/cli-installation-verified.json` e `fpm-runtime-verified.json`; prova anterior conservada antes da renovação.
|
||||
|
||||
**Pendente:** o proprietário abrir `/management/demography/install` e confirmar os lotes na sua sessão com MFA recente; depois conferir 12 lotes/920456 observações na homologação. A verificação final ainda encontrou esquema ausente e zero recibos autenticados, pelo que não se declara esta incorporação concluída. A conferência visual na sessão do proprietário mantém-se distinta da verificação técnica. Novos exemplares num PHP web sem processos requerem a preparação CLI correspondente, com o mesmo recibo e controlos, antes da confirmação no editor.
|
||||
|
||||
## 13/11/2026 — recusa de acesso demográfico corrigida em df543a44
|
||||
|
||||
A conta tinha o estatuto de superutilizador designado, email verificado e MFA configurado, mas não passava no gate legado `manage-portal`. Os registos confirmaram autenticação concluída seguida de403 em `/management/demography/install` às13:42:56(Africa/Johannesburg). A rota antiga aplicava o filtro de catálogo antes da autorização própria do instalador. A correcção `df543a4414d1641422a45262837435429ddae9d6` remove apenas esse filtro redundante das rotas demográficas e reconhece os superutilizadores existentes no importador; mantêm-se autenticação, email verificado, precedência das restrições do Auditor, autorização por acção e MFA de sessão para escrita. Não foram atribuídos papéis nem alteradas contas/permissões.
|
||||
|
||||
Revisão confirmada porHTTPS no servidor às13:48:56. Três testes focados/17asserções aprovados;pipeline Git concluída. Conferência de leitura confirmou ambos os titulares designados autorizados, destino correcto e hashes actuais do código/configuração. A prova anterior de instalação foi conservada e a prova actual renovada para esta revisão, evitando um bloqueio de botão por código desactualizado. Recibo `outputs/demography-20261113/homologacao/access-correction-verified.json`.
|
||||
|
||||
**Estado:** correcção instalada; resta verificar a abertura na sessão real do proprietário e executar as confirmações autenticadas dos lotes. O esquema demográfico remoto ainda não estava instalado nesta verificação; não se declaram os12lotes/920456observações disponibilizados na homologação. A operação local continua preservada. Não repetir preparação, atribuir permissões ou reinstalar os recortes anteriores.
|
||||
|
||||
## 13/11/2026 — revisão b20068d0 instalada em teste-investidor; dados por confirmação autenticada
|
||||
|
||||
Instalação Git concluída às **13:34:06 Africa/Johannesburg (11:34:06 UTC)** em `https://teste-investidor.tempo.co.mz`, revisão `b20068d04bc70e8fa94b90692e07afc60b406910`. `/build/revision.json`, `/localizacoes/demografia` e `/api/v1/investment/demography` responderam HTTP200, com TLS verificado. O código e a configuração estão disponíveis; **isto não equivale a ter os 12 lotes importados neste destino**.
|
||||
|
||||
Backup/restauro conferiu 190 tabelas e 369 ficheiros. Comparação posterior confirmou as onze tabelas de negócio protegidas sem alterações, incluindo instituições, versões e interesses, dossiers, oportunidades e projectos. Conferência territorial: 168 correspondências existentes, 15 acréscimos expressamente autorizados, nenhuma correspondência por resolver; 12 originais privados por SHA256, total esperado 920456 observações. Apenas três parâmetros próprios de demografia diferem da configuração anterior: activação, pasta privada de originais e interpretador Python. Nenhuma permissão, conta ou segredo foi substituído.
|
||||
|
||||
O instalador conserva MFA/autorização e confirmações por coorte. Falta o proprietário executar em `/management/demography/install`: «Instalar e disponibilizar» (três exemplares), regressar ao mesmo endereço para «Disponibilizar restantes províncias» (oito) e, finalmente, «Disponibilizar Zambézia». Conferir depois recibos autenticados, 12 lotes/920456 observações, consulta por localização e preservação dos dados. Não repetir etapas concluídas nem substituir o cadastro ou a base remota pela base local.
|
||||
|
||||
Recibo `outputs/demography-20261113/homologacao/installation-verified.json`; pacote imutável `outputs/entrega-demografia-homologacao-20261113.zip`, SHA256 `fa6ce1fc141cfec90d149c00946cea17330c09fd6bc5d8bbb1cb2abfa97f9520`, criado antes da instalação. Delta e reversão conferidos em índice temporário; índice original preservado. A conferência visual administrativa permanece separada. As menções históricas abaixo a instalação remota pendente estão substituídas por este recibo; a incorporação remota dos dados continua por confirmar.
|
||||
|
||||
## 13/11/2026 — Zambézia instalada; doze lotes demográficos verificados
|
||||
|
||||
A operação autenticada daZambézia terminou às **11:33:38Africa/Johannesburg (09:33:38UTC)**. Verificação posterior exclusivamente de leitura confirmou `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`,lote `9c8f1c48-a29d-4ca7-9ae5-23bd8ca2d05b`,estado `approved` e126684observações de2017–2050. Recibo autenticado confirmado;nenhuma repetição da importação pelo agente.
|
||||
|
||||
**Disponível no portal operacional local `investemoz:5432`:12lotes aprovados,total920456observações.** Todos os ficheiros provinciais fornecidos foram incorporados,além do exemplar nacional. A consulta provincial daZambézia devolve dados e os cinco distritos autorizados(Dérre,Luabo,Mocubela,Molumbo,Mulevala) estão consultáveis. Onze lotes anteriores preservados por contagens/SHA de metadados,observações e eventos,assim como as oito tabelas de negócio protegidas. Não há pendência de importação desta província.
|
||||
|
||||
Provas `outputs/demography-20261113/zambezia/installation-status.json`,`verification.json` e `outputs/demography-20261113/operational-status.json`. As advertências/unidades ausentes dos exemplares anteriores continuam preservadas;nenhuma correcção de estatísticas,recálculo de fórmulas ou preenchimento de lacunas. O novo original não apresentou advertências detectadas. Permanecem separados a conferência visual administrativa,indicadores sem valores no acervo e a instalação em ambientes diferentes deste portal local. Não se declara o portal inteiro concluído.
|
||||
|
||||
Este registo substitui as pendências históricas de ficheiro em falta ou operação autenticada daZambézia descritas abaixo,sem apagar o histórico.
|
||||
|
||||
## 13/11/2026 — oito províncias concluídas; Zambézia fornecida e preparada separadamente
|
||||
|
||||
A extensão anterior foi executada comMFA às11:18:05Africa/Johannesburg (09:18:05UTC) e verificada em leitura:11lotes aprovados/793772observações,recibo autenticado único,oito séries provinciais2017–2050 disponíveis,oito distritos novos consultáveis e preservação integral das oito tabelas de negócio e dos três lotes originais(importações/observações/eventos). Não foi repetida a importação. Recibo `outputs/demography-20261113/remaining-provinces/installation-status.json`;essa extensão deixa de ser pendência.
|
||||
|
||||
O proprietário forneceu depois `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`:126684observações de2017–2050,sem advertências detectadas. Autorizou expressamente Dérre,Luabo,Mocubela,Molumbo,Mulevala;referências sem códigos oficiais ou geometrias inferidos. Restantes correspondências únicas no cadastro existente,sem nova fusão ou renomeação.
|
||||
|
||||
Coorte separada `zambezia` no mesmo instalador:uma entrada,sem reimportar os onze lotes ou invalidar a recuperação dos recibos das coortes anteriores. O estado protegido abrange agora as793772observações anteriores,metadados e eventos,conferidos em páginas500. O backup/restauro mantém comparação exacta dos bytes com cursores de250linhas e ordenaçãoC,sem materializar tabelas inteiras emPHP. Novo backup/restauro299tabelas/1268ficheiros. Dois testes focados/32asserções com limite256MiB confirmaram acréscimo126684,total920456/12lotes,repetição sem duplicação,preservação dos onze lotes,MFA e os cinco distritos consultáveis;tipos,análisePHP e build aprovados. Provas anteriores de importação e integridade NDJSON reutilizadas,não reexecutadas globalmente.
|
||||
|
||||
**Estado:** código/interface e readiness disponibilizados. Falta a operação autenticada do proprietário no botão **Disponibilizar Zambézia**,em `/management/demography/install`,e conferir o recibo. Os onze lotes anteriores permanecem disponíveis. A indicação histórica de ficheiro daZambézia em falta está ultrapassada pela recepção deste original;não confundir preparação com importação concluída. Recibos `outputs/demography-20261113/zambezia/verification.json`, `release-readiness.json` e posterior `installation-status.json`. Conferência visual administrativa e ressalvas das séries anteriores permanecem separadas.
|
||||
|
||||
## 13/11/2026 — restantes províncias preparadas; correcção de memória verificada
|
||||
|
||||
Pedido expresso de instalar os oito ficheiros disponíveis:Cabo Delgado92808,Gaza82618,Maputo Província49504,Maputo Cidade44064,Nampula131400,Niassa93632,Sofala77108,Tete67932. Acréscimo previsto639066observações,total793772com os três lotes iniciais. Zambézia não tem ficheiro provincial no acervo fornecido;os dados nacionais2017 não são declarados cobertura anual dessa província. O proprietário autorizou explicitamente Chongoene/Limpopo/Mapai(Gaza),Larde/Liupo(Nampula),Chimbonila(Niassa),Doa/Marara(Tete),sem códigos oficiais ou geometria inferidos. Confirmou que os rótulos Nampula/Lichinga/Xai-Xai se ligam às cidades,preservando Rapale/Chimbonila/Limpopo separados. Correspondências históricas são apresentadas antes da confirmação autenticada;cadastro existente não é renomeado ou fundido.
|
||||
|
||||
Leitor adaptado aos títulos “Cidade da”/“Distrito Municipal”,conservando cabeçalhos e valores originais. Células com fórmulas usam somente a cache numérica do original,com advertência e fórmula conservada;nenhuma fórmula é executada/recalculada e cache ausente continua bloqueante. Valores em falta e divergências não são substituídos. Três lotes aprovados permanecem intactos e protegidos pela extensão,que não reexecuta a migração nem cria permissões.
|
||||
|
||||
A tentativa operacional às11:07foi revertida por esgotar256MiB na conferência de preservação. Corrigidas leitura/hash em páginas500 e importação porNDJSON vinculado à prévia porSHA256,com confirmação transaccional de contagens e compatibilidade com préviasJSON antigas. Relatórios completos permanecem guardados;consulta pública carrega contagens de advertências uma vez por lote. A interface passa a distinguir resposta sem reciboJSON e orienta conferência antes de repetir.
|
||||
|
||||
**Verificação:** oito testes do leitor;seis testes de instalação/50asserções incluindo639066novas observações,11lotes/793772total,preservação e replay com o mesmo limite256MiB do servidor;12testes de integração/42asserções,incluindo adulteração e contagemNDJSON;tipos,análisePHP focada,lint e build aprovados. Novo backup/restauro299tabelas/1157ficheiros. Código/interface e readiness actualizados;nenhum dado adicional aplicado pelo agente. Resta a operação autenticada no botão **Disponibilizar restantes províncias** em `/management/demography/install` e a conferência do recibo. Os três lotes originais continuam disponíveis. Provas `outputs/demography-20261113/remaining-provinces/verification.json`,`release-readiness.json` e`installation-status.json`.
|
||||
|
||||
## 13/11/2026 — instalação demográfica concluída e verificada no portal operacional local
|
||||
|
||||
A operação autenticada foi concluída em **13/11/2026 às10:39:19 Africa/Johannesburg (08:39:19UTC)** no destino confirmado `investemoz:5432`. Verificação posterior exclusivamente de leitura confirmou os três identificadores apresentados pelo operador,estado `approved`,hashes exactos dos originais e **154706observações**:Manica71604,Inhambane82620,nacional482. Recibo autenticado único,sem repetição da instalação/importação. A prova anterior desactualizada após a correcção do interpretador foi reconciliada com a versão actual;cinco testes focados das guardas/12asserções passaram emQA,sem alterar MFA ou dispensar conferências.
|
||||
|
||||
**Utilizável:** `https://portal-investidor.test/localizacoes/demografia`. GONDOLA eGURO,sectorIndústria,2017–2050 devolveram estado disponível,uma série e1836valores por distrito;Macate eVanduzi constam dos territórios consultáveis. As oito tabelas protegidas constantes do recibo mantêm contagens eSHA idênticos:projectos,dossiers/oportunidades,necessidades de competências,clusters,preparação e simulações. As seis advertências deManica,24valores ausentes e sete cabeçalhos sem unidade continuam preservados;nenhuma correcção estatística presumida.
|
||||
|
||||
**Estado actual:** instalação/importação e conferência técnica deste recorte concluídas. Restam conferência visual administrativa e ressalvas estatísticas já identificadas;outras províncias/indicadores e instalação noutros ambientes não estão concluídos por esta operação. A API sem selecção territorial devolve indisponível por ausência de contexto;a consulta comGONDOLA/GURO foi confirmada disponível. Não confundir esse estado inicial com falha da instalação.
|
||||
|
||||
Recibos `outputs/demography-20261113/installation-verified.json` e `operational-status.json`;este registo substitui as pendências históricas de execução abaixo,sem apagar as provas anteriores.
|
||||
|
||||
## 13/11/2026 — homologação demográfica e instalação operacional preparadas
|
||||
|
||||
Proprietário confirmou `investemoz:5432` no portal operacional local, incluindo três ficheiros e Macate/Vanduzi. Backup/restauro conferidos: 296 tabelas/872 ficheiros; cinco testes de instalação na cópia restaurada (21 asserções) e 11 regressões de integração (36 asserções) aprovados. Palavra-passe, TOTP e prévia administrativa QA passaram após corrigir apenas o formato do envelope da conta sintética. Controlos e credenciais operacionais preservados.
|
||||
|
||||
Frontend/instalador publicados, `.env` intacto. **Dados activos ainda não instalados**: abrir `https://portal-investidor.test/management/demography/install`, autenticar-se como gestor de instalação habitual com MFA de sessão, conferir destino/ficheiros/advertências e executar **Instalar e disponibilizar**. É o controlo autenticado existente, não uma nova autorização organizacional. A ferramenta de navegador está indisponível; não foi contornada a autenticação. Clientes CLI apresentaram impedimentos na conferência TLS da cadeia local, sem alterações de certificados ou redução da validação.
|
||||
|
||||
A acção instala apenas a migração demográfica, acrescenta quando ausentes os dois distritos autorizados e importa/aprova os três exemplares exactos com histórico, hashes, scans e contagens homologadas. Confere preservação de dossiers, simulações, instituições financeiras, oportunidades, competências e projectos/clusters. Repetição conserva contagens. Após executar: verificar três lotes/154 706 observações e comparação territorial no portal; manter as seis advertências de Manica/2031 e as unidades/edições ausentes identificadas.
|
||||
|
||||
Recibos `outputs/demography-20261113/release-readiness.json`, `installation-tests.log`, `qa-http-homologation.json`, `operational-status.json`. Relatório e reversão na adenda de `docs/investmoz/v2/demografia-ine-homologacao-2026-11-13.md`. Não declarar disponibilização dos dados concluída antes de existir o recibo autenticado.
|
||||
|
||||
## 13/11/2026 — demografia INE em desenvolvimento/QA
|
||||
|
||||
Entrega implementada na base nova `investmoz_demography_test:55441`, sem migração, importação, permissões ou publicação no activo. Inventário consultado: cadastro/caminhos territoriais, mapa, comparação, simulador, percurso e fontes SWOT existentes; provas e aceitações anteriores conservadas. O delta acrescenta consulta demográfica aos consumidores existentes, sem substituir cálculos, contexto, projectos ou fontes. Não foi feita uma nova aceitação global do portal nem declarada reposição de funcionalidade desaparecida.
|
||||
|
||||
**Utilização:** em `http://127.0.0.1:18813/localizacoes/demografia`, escolher até quatro províncias/distritos, sector e anos. GONDOLA/GURO permitem comparar a evolução 2017–2050, consultar originais por folha/célula e distinguir edições. Na oportunidade, comparação, simulador e localização documentada de projecto/cluster, abrir “Demografia na análise de viabilidade”; o acesso leva também à selecção temporal. Na preparação/SWOT do dossier, a fonte “Contexto demográfico INE” conserva a revisão e as conclusões manuais existentes. Não equiparar população a procura ou mão-de-obra efectiva.
|
||||
|
||||
**Administração QA:** `/management/demography`, usando a permissão de gestão e MFA existentes. Preparar → conferir correspondências/advertências → confirmar → aprovar utilização em QA. Desactivar retira a série das análises e conserva observações/eventos. Edições e hashes coexistem. Manica: 71 604 observações; Inhambane: 82 620; nacional: 482; total 154 706. Macate/Vanduzi existem apenas no cadastro QA por resposta expressa do proprietário. Seis advertências de Manica/2031, 24 valores urbanos/rurais ausentes e sete cabeçalhos vitais sem unidade permanecem visíveis; não foram preenchidos ou corrigidos por inferência.
|
||||
|
||||
**Verificação e saldo:** leitor (6 testes), integração (11 testes), regressão frontend de mapa/preparação/simulador (38 testes), TypeScript, análise PHP focada e build QA aprovados. Consulta pública conferida em Chromium a 1440 e 390 px, com dois distritos, filtros conservados e sem overflow da página. Conferência visual administrativa pendente: duas tentativas de navegação após desafio TOTP da conta sintética não avançaram; paragem dessa frente, sem contornar MFA. Permissões/MFA/importação continuam cobertas pelos testes HTTP. PHPStan global tem 19 erros em três ficheiros de pesquisa intactos e a formatação global tem falhas preexistentes; não declarar CI integral aprovado.
|
||||
|
||||
Relatório e procedimento de homologação: `docs/investmoz/v2/demografia-ine-homologacao-2026-11-13.md`. Recibos, hashes, reconciliação de 72 dimensões entre exemplares, screenshots e diagnóstico de navegação em `outputs/demography-20261113/`. Implementação QA disponível; homologação com ressalvas estatísticas e conferência visual administrativa pendentes. Incorporar outras províncias ou instalar no activo continua fora desta execução.
|
||||
|
||||
## 11/11/2026 — Mineração e Energia instaladas em f2767818
|
||||
|
||||
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
|
||||
|
||||
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
|
||||
|
||||
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
|
||||
|
||||
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
|
||||
|
||||
## Energia — consulta e preparação no mesmo dossier (QA,11/11/2026)
|
||||
|
||||
1. Abra **Sectores de investimento → Energia** (`/sectores/energia-especifica`). Seleccione localização e segmento energético,ou pesquise uma actividade.
|
||||
@@ -9646,3 +9867,195 @@ S08 permanece por concluir, separada da revis?o visual; a especifica??o da janel
|
||||
### 10/11/2026 ? S08: melhoria do percurso existente de licenciamento pr?vio
|
||||
|
||||
?mbito esclarecido pela Tempo; substitui o anterior saldo ?por identificar?. Implementa??o e sete provas frontend dirigidas em `67344418`: estado sem repeti??o, registo/ac??o principal vis?veis, redu??o dos blocos expans?veis e abertura/foco da etapa por `#S08`, preservando campos/contexto/vers?o/grava??o e guardas existentes. TypeScript, lint dirigido e build QA aprovados. Sem altera??o de backend/hist?rico/dados ou repeti??o das provas aceites n?o afectadas. Detalhes e limites em `docs/investmoz/v2/auditoria-portal/ajustes-por-ambito-2026-11-10.md`. Revis?o visual e instala??o mant?m-se pendentes e separadas. Pacote contador/aliases preservado; sem push e cluster 55441 mantido ligado.
|
||||
|
||||
|
||||
## 13/11/2026 — relatórios agrupados no dossier: implementação em QA
|
||||
|
||||
Pedido do proprietário: verificar e resolver a dispersão das saídas produzidas pelas etapas do investimento. No percurso existente `/investimentos/{id}#relatorios`, o índice reúne resultados financeiros, versões fiscais preservadas, comparação/preparação da empresa, guias guardados, análise SWOT, exportações APIEX, preparação de acesso à terra e documentos das etapas. Cada referência conserva dossier, oportunidade ou contexto comum/por confirmar, revisão, data quando registada e estado; etapas sem resultado associado são explicitadas. A mesma versão documental ligada a mais de um pedido aparece uma vez, sem fundir originais diferentes.
|
||||
|
||||
Exportação consolidada Word e HTML para impressão/PDF reutiliza os geradores existentes e os cálculos de viabilidade já aceites. Inclui contexto do investimento, etapas e pendências, financiamento/orçamento/empréstimo/ponto de equilíbrio recuperados das entradas guardadas, conclusões SWOT com fonte/data/classificação, preparação APIEX e referências aos documentos. Exportações APIEX históricas mantêm o original, sem acrescentar SWOT privada ao pacote destinado à entidade externa. O índice dá acesso aos originais pelas autorizações existentes; o relatório não envia mensagens, submete processos, concede crédito, executa pagamentos ou publica conteúdo.
|
||||
|
||||
A cópia fiscal preservada no rascunho é recuperável pelo titular em `/investimentos/{id}/relatorios/fiscais/{reportId}`, incluindo quando a simulação original era anónima ou deixou de existir. A posse de acesso administrativo ao dossier não concede acesso financeiro. Fontes fiscais novas conservam a oportunidade/revisão no momento da associação; versões antigas sem essa identificação permanecem disponíveis como âmbito por confirmar, sem serem atribuídas silenciosamente à oportunidade actual. Preparação de terra não é apresentada como título DUAT. Seleccionar oportunidade é necessário antes de consolidar um dossier com várias escolhas.
|
||||
|
||||
Provas: `outputs/dossier-reports-20261113/`. Vinte e um testes frontend aprovados; prova final do servidor com 44 asserções, em dados sintéticos e transacção revertida, além da regressão focada anterior de quatro testes/151 asserções. Sem migrações ou novas permissões. Estado: implementado em QA; instalação desta alteração e conferência visual no uso normal ainda pendentes. O saldo dos restantes módulos e resíduos fiscais permanece inalterado.
|
||||
|
||||
|
||||
Relatórios do dossier — conferência final de 13/11/2026: regressão focada do servidor 4 testes/168 asserções; caso final de recuperação, descargas privadas, âmbito, repetição e bloqueio com módulo desactivado 45 asserções. Os 21 testes frontend passaram; tipos, análise PHP, lint e compilação QA aprovados. Delta próprio de relatórios preparado, sem instalação remota e sem interferir na incorporação demográfica em curso.
|
||||
|
||||
|
||||
## 13/11/2026 — relatórios do dossier instalados na homologação em 07182c7c
|
||||
|
||||
Entrega autorizada pelo proprietário e instalada pelo executor Git em `https://teste-investidor.tempo.co.mz`. A revisão servida é `07182c7c735efa88d850551da71b671c3dadfa9d`. Entrada: `/investimentos`, abrir o dossier autorizado e a secção `#relatorios`. Índice por etapa e exportação consolidada Word/HTML disponíveis; cópias fiscais preservadas são recuperadas pelo titular a partir do próprio dossier. Continua sem envio ou publicação automática.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## 11/11/2026 — Mineração e Energia instaladas em f2767818
|
||||
|
||||
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
|
||||
|
||||
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
|
||||
|
||||
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
|
||||
|
||||
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
|
||||
|
||||
## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA
|
||||
|
||||
**Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,13 @@
|
||||
## 11/11/2026 — Mineração e Energia instaladas em f2767818
|
||||
|
||||
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
|
||||
|
||||
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
|
||||
|
||||
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
|
||||
|
||||
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
|
||||
|
||||
## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA
|
||||
|
||||
**Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
## 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.
|
||||
|
||||
A revisão `4988ca89dc6dddbde4efdada24546767ffcdd411` permite consumir preparações CLI privadas, vinculadas ao SHA256 do original, ano de referência, versão do preparador, prévia e fluxo NDJSON. Conserva a cópia do original, análise antimalware actual, correspondência territorial, confirmação com MFA, gravação por blocos, verificação de contagens e histórico. O CLI existente ganhou a emissão do recibo; não recalcula fórmulas nem altera estatísticas. Sem preparação válida e sem suporte a processos, a interface informa o impedimento, em vez de provocar 500. Não foi alterada a configuração de segurança do PHP.
|
||||
|
||||
Doze originais foram preparados fora do servidor web e conferidos: 920456 observações esperadas, ainda privadas e não importadas. Passaram dois testes/20 asserções com `proc_open` desactivado, 14 testes/52 asserções dos consumidores, oito testes do preparador e análise PHP. Delta e reversão foram conferidos; pacote `outputs/entrega-demografia-cli-20261113.zip`, SHA256 `4b3be263f34d6b9675b77f1ad25534f25da927d4046f57798644dce9fde24244`, preservando os pacotes anteriores.
|
||||
|
||||
**Instalado e verificado:** revisão confirmada por HTTPS às 14:06:11 Africa/Johannesburg. A verificação privada pelo socket PHP-FPM existente confirmou às 14:05:27: SAPI `fpm-fcgi`, processos continuam bloqueados, preparações disponíveis, antimalware disponível e hashes de código/configuração correctos. Não criou actor/sessão, endpoint HTTP público, permissões, migrações ou importações. Provas `outputs/demography-20261113/homologacao/cli-installation-verified.json` e `fpm-runtime-verified.json`; prova anterior conservada antes da renovação.
|
||||
|
||||
**Pendente:** o proprietário abrir `/management/demography/install` e confirmar os lotes na sua sessão com MFA recente; depois conferir 12 lotes/920456 observações na homologação. A verificação final ainda encontrou esquema ausente e zero recibos autenticados, pelo que não se declara esta incorporação concluída. A conferência visual na sessão do proprietário mantém-se distinta da verificação técnica. Novos exemplares num PHP web sem processos requerem a preparação CLI correspondente, com o mesmo recibo e controlos, antes da confirmação no editor.
|
||||
|
||||
## 13/11/2026 — recusa de acesso demográfico corrigida em df543a44
|
||||
|
||||
A conta tinha o estatuto de superutilizador designado, email verificado e MFA configurado, mas não passava no gate legado `manage-portal`. Os registos confirmaram autenticação concluída seguida de403 em `/management/demography/install` às13:42:56(Africa/Johannesburg). A rota antiga aplicava o filtro de catálogo antes da autorização própria do instalador. A correcção `df543a4414d1641422a45262837435429ddae9d6` remove apenas esse filtro redundante das rotas demográficas e reconhece os superutilizadores existentes no importador; mantêm-se autenticação, email verificado, precedência das restrições do Auditor, autorização por acção e MFA de sessão para escrita. Não foram atribuídos papéis nem alteradas contas/permissões.
|
||||
|
||||
Revisão confirmada porHTTPS no servidor às13:48:56. Três testes focados/17asserções aprovados;pipeline Git concluída. Conferência de leitura confirmou ambos os titulares designados autorizados, destino correcto e hashes actuais do código/configuração. A prova anterior de instalação foi conservada e a prova actual renovada para esta revisão, evitando um bloqueio de botão por código desactualizado. Recibo `outputs/demography-20261113/homologacao/access-correction-verified.json`.
|
||||
|
||||
**Estado:** correcção instalada; resta verificar a abertura na sessão real do proprietário e executar as confirmações autenticadas dos lotes. O esquema demográfico remoto ainda não estava instalado nesta verificação; não se declaram os12lotes/920456observações disponibilizados na homologação. A operação local continua preservada. Não repetir preparação, atribuir permissões ou reinstalar os recortes anteriores.
|
||||
|
||||
## 13/11/2026 — revisão b20068d0 instalada em teste-investidor; dados por confirmação autenticada
|
||||
|
||||
Instalação Git concluída às **13:34:06 Africa/Johannesburg (11:34:06 UTC)** em `https://teste-investidor.tempo.co.mz`, revisão `b20068d04bc70e8fa94b90692e07afc60b406910`. `/build/revision.json`, `/localizacoes/demografia` e `/api/v1/investment/demography` responderam HTTP200, com TLS verificado. O código e a configuração estão disponíveis; **isto não equivale a ter os 12 lotes importados neste destino**.
|
||||
|
||||
Backup/restauro conferiu 190 tabelas e 369 ficheiros. Comparação posterior confirmou as onze tabelas de negócio protegidas sem alterações, incluindo instituições, versões e interesses, dossiers, oportunidades e projectos. Conferência territorial: 168 correspondências existentes, 15 acréscimos expressamente autorizados, nenhuma correspondência por resolver; 12 originais privados por SHA256, total esperado 920456 observações. Apenas três parâmetros próprios de demografia diferem da configuração anterior: activação, pasta privada de originais e interpretador Python. Nenhuma permissão, conta ou segredo foi substituído.
|
||||
|
||||
O instalador conserva MFA/autorização e confirmações por coorte. Falta o proprietário executar em `/management/demography/install`: «Instalar e disponibilizar» (três exemplares), regressar ao mesmo endereço para «Disponibilizar restantes províncias» (oito) e, finalmente, «Disponibilizar Zambézia». Conferir depois recibos autenticados, 12 lotes/920456 observações, consulta por localização e preservação dos dados. Não repetir etapas concluídas nem substituir o cadastro ou a base remota pela base local.
|
||||
|
||||
Recibo `outputs/demography-20261113/homologacao/installation-verified.json`; pacote imutável `outputs/entrega-demografia-homologacao-20261113.zip`, SHA256 `fa6ce1fc141cfec90d149c00946cea17330c09fd6bc5d8bbb1cb2abfa97f9520`, criado antes da instalação. Delta e reversão conferidos em índice temporário; índice original preservado. A conferência visual administrativa permanece separada. As menções históricas abaixo a instalação remota pendente estão substituídas por este recibo; a incorporação remota dos dados continua por confirmar.
|
||||
|
||||
## 13/11/2026 — entrega remota autorizada, em execução
|
||||
|
||||
Destino autorizado pelo proprietário: `https://teste-investidor.tempo.co.mz`, base `teste_investidor:5432`. A revisão anterior confirmada é `f2767818`; a demografia ainda não estava instalada nesse servidor. Backup de 190 tabelas e 369 ficheiros com restauro isolado integral aprovado. Doze originais privados transferidos e conferidos por SHA256, sem copiar a base local. O exemplar partilhado actual de Inhambane diverge do aprovado; foi usada a cópia privada aceite, de hash `51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818`, preservando ambas as versões.
|
||||
|
||||
Adaptação delimitada: activação explícita de consulta na homologação, identidade real da base conferida e pasta privada configurável para originais. Permanecem autenticação/MFA, backup/restauro, controlo de integridade, operações idempotentes e preservação dos dados. Testes focados: 13/45 asserções de consumidores, 5/12 dos controlos de instalação e 1/6 do destino remoto. A entrega Git não executa a importação nem a aprovação dos lotes: os dados continuam sujeitos à operação autenticada existente. Instalação remota e conferência de contagens ainda por confirmar neste ponto do histórico.
|
||||
|
||||
## 13/11/2026 — Zambézia instalada; doze lotes demográficos verificados
|
||||
|
||||
A operação autenticada daZambézia terminou às **11:33:38Africa/Johannesburg (09:33:38UTC)**. Verificação posterior exclusivamente de leitura confirmou `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`,lote `9c8f1c48-a29d-4ca7-9ae5-23bd8ca2d05b`,estado `approved` e126684observações de2017–2050. Recibo autenticado confirmado;nenhuma repetição da importação pelo agente.
|
||||
|
||||
**Disponível no portal operacional local `investemoz:5432`:12lotes aprovados,total920456observações.** Todos os ficheiros provinciais fornecidos foram incorporados,além do exemplar nacional. A consulta provincial daZambézia devolve dados e os cinco distritos autorizados(Dérre,Luabo,Mocubela,Molumbo,Mulevala) estão consultáveis. Onze lotes anteriores preservados por contagens/SHA de metadados,observações e eventos,assim como as oito tabelas de negócio protegidas. Não há pendência de importação desta província.
|
||||
|
||||
Provas `outputs/demography-20261113/zambezia/installation-status.json`,`verification.json` e `outputs/demography-20261113/operational-status.json`. As advertências/unidades ausentes dos exemplares anteriores continuam preservadas;nenhuma correcção de estatísticas,recálculo de fórmulas ou preenchimento de lacunas. O novo original não apresentou advertências detectadas. Permanecem separados a conferência visual administrativa,indicadores sem valores no acervo e a instalação em ambientes diferentes deste portal local. Não se declara o portal inteiro concluído.
|
||||
|
||||
Este registo substitui as pendências históricas de ficheiro em falta ou operação autenticada daZambézia descritas abaixo,sem apagar o histórico.
|
||||
|
||||
## 13/11/2026 — oito províncias concluídas; Zambézia fornecida e preparada separadamente
|
||||
|
||||
A extensão anterior foi executada comMFA às11:18:05Africa/Johannesburg (09:18:05UTC) e verificada em leitura:11lotes aprovados/793772observações,recibo autenticado único,oito séries provinciais2017–2050 disponíveis,oito distritos novos consultáveis e preservação integral das oito tabelas de negócio e dos três lotes originais(importações/observações/eventos). Não foi repetida a importação. Recibo `outputs/demography-20261113/remaining-provinces/installation-status.json`;essa extensão deixa de ser pendência.
|
||||
|
||||
O proprietário forneceu depois `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`:126684observações de2017–2050,sem advertências detectadas. Autorizou expressamente Dérre,Luabo,Mocubela,Molumbo,Mulevala;referências sem códigos oficiais ou geometrias inferidos. Restantes correspondências únicas no cadastro existente,sem nova fusão ou renomeação.
|
||||
|
||||
Coorte separada `zambezia` no mesmo instalador:uma entrada,sem reimportar os onze lotes ou invalidar a recuperação dos recibos das coortes anteriores. O estado protegido abrange agora as793772observações anteriores,metadados e eventos,conferidos em páginas500. O backup/restauro mantém comparação exacta dos bytes com cursores de250linhas e ordenaçãoC,sem materializar tabelas inteiras emPHP. Novo backup/restauro299tabelas/1268ficheiros. Dois testes focados/32asserções com limite256MiB confirmaram acréscimo126684,total920456/12lotes,repetição sem duplicação,preservação dos onze lotes,MFA e os cinco distritos consultáveis;tipos,análisePHP e build aprovados. Provas anteriores de importação e integridade NDJSON reutilizadas,não reexecutadas globalmente.
|
||||
|
||||
**Estado:** código/interface e readiness disponibilizados. Falta a operação autenticada do proprietário no botão **Disponibilizar Zambézia**,em `/management/demography/install`,e conferir o recibo. Os onze lotes anteriores permanecem disponíveis. A indicação histórica de ficheiro daZambézia em falta está ultrapassada pela recepção deste original;não confundir preparação com importação concluída. Recibos `outputs/demography-20261113/zambezia/verification.json`, `release-readiness.json` e posterior `installation-status.json`. Conferência visual administrativa e ressalvas das séries anteriores permanecem separadas.
|
||||
|
||||
## 13/11/2026 — restantes províncias preparadas; correcção de memória verificada
|
||||
|
||||
Pedido expresso de instalar os oito ficheiros disponíveis:Cabo Delgado92808,Gaza82618,Maputo Província49504,Maputo Cidade44064,Nampula131400,Niassa93632,Sofala77108,Tete67932. Acréscimo previsto639066observações,total793772com os três lotes iniciais. Zambézia não tem ficheiro provincial no acervo fornecido;os dados nacionais2017 não são declarados cobertura anual dessa província. O proprietário autorizou explicitamente Chongoene/Limpopo/Mapai(Gaza),Larde/Liupo(Nampula),Chimbonila(Niassa),Doa/Marara(Tete),sem códigos oficiais ou geometria inferidos. Confirmou que os rótulos Nampula/Lichinga/Xai-Xai se ligam às cidades,preservando Rapale/Chimbonila/Limpopo separados. Correspondências históricas são apresentadas antes da confirmação autenticada;cadastro existente não é renomeado ou fundido.
|
||||
|
||||
Leitor adaptado aos títulos “Cidade da”/“Distrito Municipal”,conservando cabeçalhos e valores originais. Células com fórmulas usam somente a cache numérica do original,com advertência e fórmula conservada;nenhuma fórmula é executada/recalculada e cache ausente continua bloqueante. Valores em falta e divergências não são substituídos. Três lotes aprovados permanecem intactos e protegidos pela extensão,que não reexecuta a migração nem cria permissões.
|
||||
|
||||
A tentativa operacional às11:07foi revertida por esgotar256MiB na conferência de preservação. Corrigidas leitura/hash em páginas500 e importação porNDJSON vinculado à prévia porSHA256,com confirmação transaccional de contagens e compatibilidade com préviasJSON antigas. Relatórios completos permanecem guardados;consulta pública carrega contagens de advertências uma vez por lote. A interface passa a distinguir resposta sem reciboJSON e orienta conferência antes de repetir.
|
||||
|
||||
**Verificação:** oito testes do leitor;seis testes de instalação/50asserções incluindo639066novas observações,11lotes/793772total,preservação e replay com o mesmo limite256MiB do servidor;12testes de integração/42asserções,incluindo adulteração e contagemNDJSON;tipos,análisePHP focada,lint e build aprovados. Novo backup/restauro299tabelas/1157ficheiros. Código/interface e readiness actualizados;nenhum dado adicional aplicado pelo agente. Resta a operação autenticada no botão **Disponibilizar restantes províncias** em `/management/demography/install` e a conferência do recibo. Os três lotes originais continuam disponíveis. Provas `outputs/demography-20261113/remaining-provinces/verification.json`,`release-readiness.json` e`installation-status.json`.
|
||||
|
||||
## 13/11/2026 — instalação demográfica concluída e verificada no portal operacional local
|
||||
|
||||
A operação autenticada foi concluída em **13/11/2026 às10:39:19 Africa/Johannesburg (08:39:19UTC)** no destino confirmado `investemoz:5432`. Verificação posterior exclusivamente de leitura confirmou os três identificadores apresentados pelo operador,estado `approved`,hashes exactos dos originais e **154706observações**:Manica71604,Inhambane82620,nacional482. Recibo autenticado único,sem repetição da instalação/importação. A prova anterior desactualizada após a correcção do interpretador foi reconciliada com a versão actual;cinco testes focados das guardas/12asserções passaram emQA,sem alterar MFA ou dispensar conferências.
|
||||
|
||||
**Utilizável:** `https://portal-investidor.test/localizacoes/demografia`. GONDOLA eGURO,sectorIndústria,2017–2050 devolveram estado disponível,uma série e1836valores por distrito;Macate eVanduzi constam dos territórios consultáveis. As oito tabelas protegidas constantes do recibo mantêm contagens eSHA idênticos:projectos,dossiers/oportunidades,necessidades de competências,clusters,preparação e simulações. As seis advertências deManica,24valores ausentes e sete cabeçalhos sem unidade continuam preservados;nenhuma correcção estatística presumida.
|
||||
|
||||
**Estado actual:** instalação/importação e conferência técnica deste recorte concluídas. Restam conferência visual administrativa e ressalvas estatísticas já identificadas;outras províncias/indicadores e instalação noutros ambientes não estão concluídos por esta operação. A API sem selecção territorial devolve indisponível por ausência de contexto;a consulta comGONDOLA/GURO foi confirmada disponível. Não confundir esse estado inicial com falha da instalação.
|
||||
|
||||
Recibos `outputs/demography-20261113/installation-verified.json` e `operational-status.json`;este registo substitui as pendências históricas de execução abaixo,sem apagar as provas anteriores.
|
||||
|
||||
# Demografia INE — entrega em desenvolvimento e QA, 13/11/2026
|
||||
|
||||
## Adenda posterior — homologação e disponibilização autorizadas em 13/11/2026
|
||||
|
||||
O proprietário pediu «homologar e disponibilizar» e confirmou o destino **portal operacional local, `investemoz` em `127.0.0.1:5432`, incluindo a instalação das tabelas, importação dos três ficheiros e Macate/Vanduzi**. Esta decisão substitui, para este recorte concreto, o limite anterior de QA. Não autoriza alterações noutros produtos ou concessão de permissões.
|
||||
|
||||
**Estado actual:** homologação funcional em QA aprovada; frontend e instalador publicados no portal operacional. As tabelas e os dados no activo aguardam a execução autenticada em `https://portal-investidor.test/management/demography/install`, com a identidade de instalação e MFA de sessão existentes. Não foi criada ou simulada uma sessão de utilizador operacional. Última conferência: zero observações demográficas no activo e nenhuma execução autenticada registada. A página pública responde, mas ainda comunica indisponibilidade de séries neste destino.
|
||||
|
||||
Preparação realizada:
|
||||
|
||||
- Backup consistente do activo e restauro numa base nova `demography_restore_20261113_075052_test:55441`: **296 tabelas e 872 ficheiros conferidos**, preservados em armazenamento privado. A primeira comparação diferiu apenas na ordenação das mesmas 14 sessões entre collations dos servidores; a comparação pelo multiconjunto exacto de bytes passou. Nenhuma linha foi omitida ou normalizada para obter igualdade.
|
||||
- Cinco testes adicionais de instalação, 21 asserções, aprovados na cópia restaurada. Exercitaram migração exacta, importação real de 154 706 observações, novos distritos, repetição sem duplicação, fontes e preservação do estado protegido. As identidades de ensaio e todas as mutações do ensaio ficaram numa transacção QA revertida; não foram usadas identidades operacionais para simular autorização.
|
||||
- Os 11 testes de regressão da integração voltaram a passar, agora com 36 asserções. Mantêm-se as seis verificações do leitor e os 38 testes frontend já aceites para o recorte inalterado. TypeScript, análise PHP focada e build de disponibilização aprovados. As falhas globais preexistentes continuam separadas.
|
||||
- A pendência TOTP foi delimitada ao envelope criado para a **conta sintética QA**: `encryptString` era incompatível com o `decrypt` do Fortify. Corrigida apenas essa conta, com `encrypt` serializado; palavra-passe, TOTP real e acesso à prévia administrativa passaram por HTTP autenticado. Nenhuma credencial, autenticação ou permissão operacional foi alterada. Conferência visual administrativa continua dependente da sessão do operador, pois o acesso automatizado ao navegador está indisponível.
|
||||
- Assets produzidos primeiro em `outputs/demography-20261113/release-build`, conferidos e copiados para `public/build`, mantendo os chunks anteriores para compatibilidade de páginas abertas. Configuração `.env` preservada. Recibo `release-readiness.json`; dump, ficheiros e readiness autenticada mantidos privados. A prova vincula código, manifest, hashes dos assets, destino, backup/restauro e ensaio.
|
||||
- O instalador reutiliza `PortalProfileInstallation::authorize`, incluindo o MFA efectivamente concluído na sessão. A migração operacional só é permitida dentro dessa acção; não há comando CLI de migração operacional sem actor. A importação utiliza a prévia homologada, confere os hashes exactos e contagens, faz scan dos originais e regista actor, motivo e recibo. Reenvios são serializados e idempotentes. Não é executada qualquer migração pendente fora deste recorte.
|
||||
- A autorização específica de Macate/Vanduzi é reflectida como origem `ine-demography`, com hash do exemplar e auditoria. Os dois identificadores territoriais ficam disponíveis nos selectores existentes, sem inventar código oficial, limites, geometria, vigência jurídica, licenças ou direitos. A disponibilidade territorial mantém-se independente da desactivação de uma série estatística.
|
||||
- Smoke test do kernel operacional: instalador anónimo redirecciona para autenticação; página pública e API respondem 200. Esta prova é de aplicação, não de navegador/TLS. O cliente HTTPS Windows não conseguiu conferir revogação e o cliente Python recusou a cadeia por ausência de Authority Key Identifier; não foram desactivadas verificações TLS nem alterados certificados. A navegação real deve ser conferida no navegador habitual, sem ultrapassar avisos de segurança.
|
||||
|
||||
**Reversão preparada:** o backup privado contém a configuração, código, documentos e build anteriores. Se for necessário suspender a demografia depois da instalação, desactivar as séries pelo fluxo autenticado, conservando observações e eventos; não usar rollback destrutivo ou eliminar os novos distritos ligados a investimentos. Para reverter a apresentação, repor os manifests/assets do arquivo conferido. Recuperação integral da base exige intervenção delimitada com o dump privado, nunca reposição indiscriminada sobre actividade posterior.
|
||||
|
||||
**Acção final do operador:** abrir o instalador, autenticar-se com a conta habitual e MFA, conferir o destino/ficheiros/advertências, confirmar e executar «Instalar e disponibilizar». Depois conferir o recibo autenticado, três lotes aprovados, 154 706 observações, consulta de duas localizações e contagens/checksums protegidos. A decisão de autorização já está registada; esta etapa cumpre o controlo de execução autenticada existente.
|
||||
|
||||
Ficheiros adicionais desta adenda: `app/Services/DemographicInstallation.php`, extensão da guarda de `DemographicImport` e da migração, referências em `InvestmentGeography`, métodos/rotas do controlador, `resources/js/pages/management/demography-install.tsx`, rótulos administrativos, `tests/Feature/DemographicInstallationTest.php`, ferramentas de backup/preparação/estado da entrega. Não foi necessário alterar as regras de autenticação.
|
||||
|
||||
O restante texto abaixo conserva a entrega QA original e as suas evidências históricas; a adenda acima prevalece quanto à autorização e ao estado actual.
|
||||
|
||||
Implementação local, sem instalação ou importação no activo. Base nova `investmoz_demography_test`, PostgreSQL/PostGIS em `127.0.0.1:55441`; servidor QA `http://127.0.0.1:18813`. `.env` operacional não foi alterado. A configuração operacional aponta para `investemoz:5432`; foi usada exclusivamente uma transacção de leitura para inventariar referências territoriais públicas, encerrada com rollback. Não foram copiados investidores, dossiers, credenciais ou outros dados privados.
|
||||
|
||||
## Resultado e componentes reutilizados
|
||||
|
||||
- Modelo nacional com observações por território/ano/indicador/sexo/idade/residência, unidade, tipo estatístico, valor original exacto, exemplar/hash, folha/célula, importação, validação e histórico. Edições coexistem; não há eliminação ou substituição automática.
|
||||
- Leitor XLSX por relações, títulos e cabeçalhos; valores decimais conservados como texto numérico exacto. Não executa fórmulas/macros. Duas etapas: preparação privada com scan ClamAV e prévia; confirmação transaccional. Aprovação para utilização e desactivação são acções separadas e auditadas. Hash único do original e chave única das dimensões impedem duplicação.
|
||||
- Cadastro territorial, identidades e regras de acesso existentes reutilizados. Normalização de acentos/espaços e prefixos administrativos sem correspondência aproximada; ambiguidades ficam pendentes. Correspondências manuais verificam nível, província e fusões indevidas. Macate e Vanduzi acrescentados **somente em QA**, com referência ao hash de Manica, por resposta expressa do proprietário nesta sessão. Nenhuma alteração no cadastro operacional.
|
||||
- Consulta pública `/localizacoes/demografia`, API `/api/v1/investment/demography`: até quatro territórios, sector, ano inicial/final, horizonte até 50 anos, edições separadas e anos em falta. Diferenças numéricas só para mesmo nível, ano, unidade, tipo e identificação de edição; explicação e fontes visíveis. Sem extrapolação ou percentagem de sucesso.
|
||||
- Consulta demográfica incorporada no simulador existente, nas oportunidades, na comparação de oportunidades e nas localizações documentadas de projectos/clusters. A comparação existente conserva os seus factores económicos/sectoriais; a demografia acrescenta evidência, sem alterar regras fiscais. Localização principal, implantação e área de influência não são fundidas.
|
||||
- `InvestmentPreparationWorkspace` acrescenta a evidência demográfica às fontes da SWOT do contexto escolhido; conclusões e revisão continuam no fluxo existente. O contexto deliberado e os dados fiscais não são sobrescritos.
|
||||
- Administração em `/management/demography`: envio, prévia, correspondências, advertências, confirmação, aprovação QA, desactivação e eventos. Mantidos `manage-portal`, exclusão do auditor, MFA e guarda da identidade real da base QA. Não são concedidos novos papéis ou direitos.
|
||||
|
||||
Migração: `database/migrations/2026_11_13_100000_create_demographic_series.php`, com três tabelas: `demographic_imports`, `demographic_observations`, `demographic_events`. A migração só admite QA isolado e recusa rollback que apague histórico existente. O código de consulta está limitado a local/testing; esta entrega não autoriza activação em produção.
|
||||
|
||||
## Importações efectivas
|
||||
|
||||
| Original | Observações gravadas | Territórios | Anos | Rejeições numéricas |
|
||||
| --- | ---: | --- | --- | ---: |
|
||||
| Manica.xlsx | 71 604 | 1 província + 12 distritos | 2017–2050 | 0 |
|
||||
| Inhambane.xlsx | 82 620 | 1 província + 14 distritos | 2017–2050 | 0 |
|
||||
| Dados demograficos.xlsx | 482 | 11 províncias | 2017 | 0 |
|
||||
| Total | 154 706 | 11 províncias + 26 distritos distintos | — | 0 |
|
||||
|
||||
Originais preservados, copiados apenas para armazenamento privado QA; scans reais ClamAV 1.5.4 com resultado `clean`. Hashes, correspondências individuais, folhas, anos, recibos e tempos em `outputs/demography-20261113/verification.json`. Preparar/importar/verificar repetição/aprovar demorou aproximadamente 14,1 s para Manica, 15,3 s para Inhambane e 1,5 s para o ficheiro nacional nesta máquina; não é um benchmark de capacidade nacional. Repetir a confirmação manteve as contagens.
|
||||
|
||||
Inventário integral da pasta demografia: 22 ficheiros, incluindo originais adicionais, PDFs, documentos e ficheiros temporários, com tamanho e SHA256 em `source-inventory.json`. Os restantes exemplares foram inventariados, sem importação ou activação automática. Não se pressupõe a mesma estrutura para todas as províncias.
|
||||
|
||||
## Resultados estatísticos e limitações concretas
|
||||
|
||||
Inhambane passou a reconciliação de sexo, residência, grupos etários e soma dos distritos. Em Manica há seis advertências em **2031**, preservadas:
|
||||
|
||||
| Quadro/célula | Verificação | Diferença (total menos parcelas) |
|
||||
| --- | --- | ---: |
|
||||
| Mossurize, E204 | Sexo | 200824.43870750687 |
|
||||
| Mossurize, C204 | Residência, homens | 99260.016965195867 |
|
||||
| Mossurize, D204 | Residência, mulheres | 101564.421742311014 |
|
||||
| Mossurize, F204 | Soma etária, homens urbanos | -99260.01696519587193 |
|
||||
| Mossurize, G204 | Soma etária, mulheres urbanas | -101564.42174231099699 |
|
||||
| Manica, B4 | Total provincial versus distritos | -69384.494114521566 |
|
||||
|
||||
Não foram corrigidos valores por inferência. Tolerância de reconciliação: `0.000001` pessoa, exclusivamente para resíduos de representação numérica do Excel; não arredonda valores gravados. As advertências acompanham a série, inclusive quando aprovada em QA, e devem ser consideradas ao usar o ano afectado.
|
||||
|
||||
O ficheiro nacional contém população por sexo/residência, três grandes grupos etários e seis famílias de indicadores vitais: esperança de vida, mortalidade infantil, mortalidade bruta, natalidade, fecundidade e crescimento. Esperança de vida e mortalidade infantil estão discriminadas por sexo. Há **sete cabeçalhos de unidade ausente**, abrangendo cinco famílias: três colunas de mortalidade infantil e quatro outros indicadores. Esses valores mantêm unidade `unspecified`; a esperança de vida usa anos. Não são assumidas percentagens ou valores por mil.
|
||||
|
||||
Há 24 valores ausentes de população urbana/rural de Maputo Cidade, mantidos ausentes. A folha de população não imprime ano: 2017 foi fornecido expressamente nos metadados de importação, em coerência com a adenda; as folhas etária e vital imprimem 2017. A edição de publicação não consta dos exemplares: o rótulo declara essa ausência e o SHA256 identifica cada versão. As séries anuais dizem “projectada”; o ficheiro nacional não declara observado/projectado, pelo que fica `unspecified`.
|
||||
|
||||
Reconciliação independente de população total e grandes grupos etários de Manica/Inhambane em 2017: **72 comparações coincidentes dentro da precisão**, zero divergências/incompletudes nestas comparações. As somas das faixas etárias são evidência calculada de reconciliação, não novos valores oficiais importados. A igualdade numérica não funde exemplares nem prova igualdade de metodologia/edição.
|
||||
|
||||
`Outros indicadores` contém somente 11 títulos, sem valores. Escolaridade, pobreza, água, saneamento e electricidade podem usar o modelo por indicador/unidade/dimensões, mas nenhum valor foi inventado. População em idade activa não é mão-de-obra disponível/qualificada; população residente não é procura efectiva ou poder de compra. Densidade não é calculada sem área territorial aplicável.
|
||||
|
||||
## Verificação
|
||||
|
||||
- Leitor: seis testes automatizados de cabeçalhos deslocados, decimais exactos, duplicados, incompletude, totais e idade — aprovados.
|
||||
- Integração: 11 testes — aprovados. Cobrem consulta pública, versões, anos ausentes, comparação exacta, confidencialidade territorial/ancestral, estados aprovados/desactivados, permissões, MFA, guarda QA, idempotência, fontes SWOT, hash, chave única e preservação do histórico no rollback.
|
||||
- Regressão existente: 38 testes frontend de mapa, preparação/SWOT e formulário/cálculos do simulador — aprovados. Não foi executada a suite integral; não se declara aceitação integral do portal.
|
||||
- TypeScript e análise PHP focada nos serviços/controlador alterados: aprovados. Compilação em `public/build-qa-opportunities`: aprovada; os assets operacionais não foram compilados/publicados.
|
||||
- Verificações globais: `npm run check` encontrou problemas de formatação em 56 ficheiros, incluindo ficheiros intactos; os quatro componentes/páginas novos foram formatados e passaram lint focado. PHPStan global encontrou 19 erros em `GlobalSearchController`, `FederatedSearch` e `SearchTerms`, ficheiros não alterados por esta tarefa. Estes problemas preexistentes não foram ocultados ou corrigidos fora do âmbito.
|
||||
- Consulta pública conferida visualmente em Chromium a 1440 e 390 px, com filtros mantidos, duas tabelas, comparação e sem transbordo da página. Conferência visual administrativa pendente: a conta sintética chegou ao desafio TOTP, mas duas tentativas não avançaram após o código. O diagnóstico conservou URL e apresentação; essa frente foi interrompida sem contornar MFA. Alternativa concreta: conferência manual com login/MFA QA, mantendo os testes HTTP já aprovados.
|
||||
- Provas: `verification.json`, `source-reconciliation.json`, `frontend-regression.log`, `build.log`, `browser-public-verification.json`, `browser-admin-verification.json` e screenshots em `outputs/demography-20261113/`. Conferência visual e saldo final registados no manual existente.
|
||||
|
||||
## Homologação e próximas províncias
|
||||
|
||||
1. Usar exclusivamente o servidor/base QA identificados acima. As credenciais da conta sintética de ensaio permanecem privadas; não são contas operacionais.
|
||||
2. Em Administração → Demografia INE, consultar os três lotes, as correspondências e as advertências. Repetir a confirmação de um lote deve conservar as contagens. Desactivar/aprovar em QA deve retirar/repor a série da consulta, mantendo os eventos e observações.
|
||||
3. Em Localizações → demografia, seleccionar GONDOLA e GURO, sector Indústria, 2017–2050. Conferir anos, faixas etárias, exemplares e diferenças; seleccionar um ano fora da cobertura deve mostrar ausência de dados. Repetir para províncias em 2017 para consultar exemplares nacionais e provinciais separados.
|
||||
4. Num dossier QA com território seleccionado, abrir preparação/SWOT e conferir `Contexto demográfico INE` como fonte. No simulador, consultar demografia e voltar ao rascunho sem alterar os valores fiscais. Em projectos/clusters, conferir a evidência dentro do papel territorial documentado.
|
||||
5. Registar a decisão de homologação, incluindo o tratamento das divergências de 2031. Homologar esta entrega não implica instalar, publicar, migrar ou importar no activo.
|
||||
|
||||
Recomendação: o modelo/importador estão preparados para **pré-visualizar** os restantes XLSX, um exemplar de cada vez, conferindo cabeçalhos, nomes territoriais, anos e totais antes de confirmar. O inventário também contém XLS legado e indicadores de outras naturezas: exigem adaptador/âmbito próprio, sem converter ou sobrescrever originais. Não se declara já demonstrada a importação das restantes províncias nem se autoriza execução desse trabalho.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import DemographicEvidence, {
|
||||
type DemographicAnalysis,
|
||||
} from './demographic-evidence';
|
||||
|
||||
export default function DemographicContext({
|
||||
territoryId,
|
||||
}: {
|
||||
territoryId?: string | null;
|
||||
}) {
|
||||
const [evidence, setEvidence] = useState<DemographicAnalysis | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!territoryId) return null;
|
||||
return (
|
||||
<details className="rounded border p-4">
|
||||
<summary>Demografia na análise de viabilidade</summary>
|
||||
<p>
|
||||
Consulte a população e a estrutura etária da localização
|
||||
escolhida, com fontes, anos e limitações.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="my-2 underline"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/investment/demography?territories[]=${encodeURIComponent(territoryId)}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error('Consulta indisponível.');
|
||||
setEvidence(await response.json());
|
||||
} catch {
|
||||
setMessage(
|
||||
'Não foi possível consultar a evidência demográfica.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Consultar indicadores do ano actual
|
||||
</button>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
{evidence && <DemographicEvidence evidence={evidence} />}
|
||||
<a
|
||||
className="block underline"
|
||||
href={`/localizacoes/demografia?territories[]=${encodeURIComponent(territoryId)}`}
|
||||
>
|
||||
Escolher período, sector e comparar localizações
|
||||
</a>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
export type DemographicAnalysis = {
|
||||
state: string;
|
||||
start_year: number;
|
||||
end_year: number;
|
||||
limitations: string[];
|
||||
comparisons: {
|
||||
year: number;
|
||||
left: string;
|
||||
right: string;
|
||||
left_value: string;
|
||||
right_value: string;
|
||||
left_import: string;
|
||||
right_import: string;
|
||||
difference: string | null;
|
||||
explanation: string;
|
||||
}[];
|
||||
differences: {
|
||||
territory_id: string;
|
||||
dimension: string;
|
||||
state: string;
|
||||
values: { import_id: string; value: string; data_kind: string }[];
|
||||
}[];
|
||||
territories: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: string;
|
||||
factors_to_assess: string[];
|
||||
series: {
|
||||
import_id: string;
|
||||
source: string;
|
||||
version: string;
|
||||
sha256: string;
|
||||
file_name: string;
|
||||
missing_years: number[];
|
||||
issue_counts: Record<string, number>;
|
||||
values: {
|
||||
year: number;
|
||||
indicator: string;
|
||||
age_group: string;
|
||||
residence: string;
|
||||
value: string;
|
||||
original_value: string;
|
||||
unit: string;
|
||||
data_kind: string;
|
||||
sheet: string;
|
||||
cell: string;
|
||||
validation_status: string;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const indicators: Record<string, string> = {
|
||||
population: 'População',
|
||||
life_expectancy: 'Esperança de vida',
|
||||
infant_mortality: 'Mortalidade infantil',
|
||||
crude_death_rate: 'Mortalidade bruta',
|
||||
crude_birth_rate: 'Natalidade bruta',
|
||||
fertility_rate: 'Fecundidade',
|
||||
population_growth_rate: 'Crescimento populacional',
|
||||
};
|
||||
export default function DemographicEvidence({
|
||||
evidence,
|
||||
}: {
|
||||
evidence: DemographicAnalysis;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4" aria-label="Evidência demográfica">
|
||||
{evidence.state !== 'available' && (
|
||||
<p>Sem séries demográficas aprovadas para esta selecção.</p>
|
||||
)}
|
||||
{evidence.territories.map((t) => (
|
||||
<article
|
||||
key={t.id}
|
||||
className="space-y-3 rounded border bg-white p-4 text-slate-900"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t.name} —{' '}
|
||||
{t.level === 'district' ? 'Distrito' : 'Província'}
|
||||
</h2>
|
||||
{t.factors_to_assess.map((f) => (
|
||||
<p key={f}>{f}</p>
|
||||
))}
|
||||
{t.series.length === 0 && (
|
||||
<p>Sem dados no período escolhido.</p>
|
||||
)}
|
||||
{t.series.map((s) => (
|
||||
<details key={s.import_id} open={t.series.length === 1}>
|
||||
<summary>
|
||||
{s.source} · {s.version} · {s.file_name}
|
||||
</summary>
|
||||
<p className="text-xs break-all">
|
||||
SHA256: {s.sha256}
|
||||
</p>
|
||||
{s.missing_years.length > 0 && (
|
||||
<p>
|
||||
Anos sem dados: {s.missing_years.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{Object.keys(s.issue_counts).length > 0 && (
|
||||
<details>
|
||||
<summary>Advertências da série</summary>
|
||||
<pre className="overflow-auto text-xs">
|
||||
{JSON.stringify(
|
||||
s.issue_counts,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="text-left">
|
||||
Valores originais por ano e dimensão —
|
||||
versões apresentadas separadamente
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Ano',
|
||||
'Indicador',
|
||||
'Idade',
|
||||
'Residência',
|
||||
'Valor',
|
||||
'Unidade',
|
||||
'Tipo',
|
||||
'Referência',
|
||||
].map((h) => (
|
||||
<th className="p-2" key={h}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.values.map((v) => (
|
||||
<tr
|
||||
key={`${v.sheet}:${v.cell}`}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
{v.year}
|
||||
</td>
|
||||
<td>
|
||||
{indicators[v.indicator] ??
|
||||
v.indicator}
|
||||
</td>
|
||||
<td>
|
||||
{v.age_group === 'all'
|
||||
? 'Todas'
|
||||
: v.age_group}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
{
|
||||
all: 'Total',
|
||||
urban: 'Urbana',
|
||||
rural: 'Rural',
|
||||
}[v.residence]
|
||||
}
|
||||
</td>
|
||||
<td>{v.original_value}</td>
|
||||
<td>
|
||||
{v.unit === 'unspecified'
|
||||
? 'Não indicada na fonte'
|
||||
: v.unit}
|
||||
</td>
|
||||
<td>
|
||||
{v.data_kind === 'projected'
|
||||
? 'Projecção'
|
||||
: 'Não especificado'}
|
||||
</td>
|
||||
<td>
|
||||
{v.sheet}!{v.cell}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
{evidence.comparisons.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Diferenças entre localizações
|
||||
</h2>
|
||||
{evidence.comparisons.map((c) => (
|
||||
<p
|
||||
key={`${c.year}:${c.left_import}:${c.right_import}:${c.left}:${c.right}`}
|
||||
>
|
||||
{c.year}: {c.left} ({c.left_value}) / {c.right} (
|
||||
{c.right_value}). Diferença:{' '}
|
||||
{c.difference ?? 'não comparável'}. {c.explanation}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{evidence.differences.length > 0 && (
|
||||
<details>
|
||||
<summary>
|
||||
Reconciliação entre exemplares:{' '}
|
||||
{evidence.differences.length} dimensões comuns
|
||||
</summary>
|
||||
<ul>
|
||||
{evidence.differences.map((d) => (
|
||||
<li key={`${d.territory_id}:${d.dimension}`}>
|
||||
{d.dimension}:{' '}
|
||||
{d.state === 'matching_values'
|
||||
? 'valores coincidentes; conservar ambas as fontes'
|
||||
: 'valores divergentes'}{' '}
|
||||
—{' '}
|
||||
{d.values
|
||||
.map((v) => `${v.value} (${v.data_kind})`)
|
||||
.join(' / ')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<ul className="list-disc pl-5 text-sm">
|
||||
{evidence.limitations.map((l) => (
|
||||
<li key={l}>{l}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
createInvestorDossierReport,
|
||||
createInvestorStageReport,
|
||||
investorDossierReportIndex,
|
||||
type InvestorDossierReportInput,
|
||||
} from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
createProjectDossierPrintHtml,
|
||||
generateProjectDossierDocx,
|
||||
} from '@/lib/project-dossier-report';
|
||||
|
||||
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);
|
||||
async function exportReport(format: 'word' | 'html') {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const model = stage
|
||||
? createInvestorStageReport(dossier, stage)
|
||||
: createInvestorDossierReport(dossier);
|
||||
const blob =
|
||||
format === 'word'
|
||||
? await generateProjectDossierDocx(model, [], true)
|
||||
: new Blob(
|
||||
[
|
||||
await createProjectDossierPrintHtml(
|
||||
model,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
],
|
||||
{ type: 'text/html;charset=utf-8' },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
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
|
||||
? cause.message
|
||||
: 'Não foi possível preparar o relatório. Os registos foram preservados.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section id="relatorios" className="space-y-4 rounded-xl border p-4">
|
||||
<h2 className="text-lg font-semibold">Relatórios deste dossier</h2>
|
||||
<p>
|
||||
Dossier {dossier.case.title} · versão {dossier.case.version} ·
|
||||
oportunidade {dossier.context_opportunity ?? 'contexto comum'}.
|
||||
</p>
|
||||
<p>
|
||||
Reúna as etapas, a viabilidade, a análise SWOT e a preparação
|
||||
APIEX. O relatório mantém os estados e as pendências; os
|
||||
originais continuam nas respectivas versões.
|
||||
</p>
|
||||
{requiresChoice ? (
|
||||
<p role="status">
|
||||
Seleccione a oportunidade em tratamento para reunir os seus
|
||||
relatórios sem misturar contextos.
|
||||
</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')}
|
||||
>
|
||||
Preparar{' '}
|
||||
{stage
|
||||
? `relatório ${stage}`
|
||||
: 'relatório consolidado'}{' '}
|
||||
(Word)
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('html')}
|
||||
>
|
||||
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
|
||||
processos ou mensagens a entidades externas.
|
||||
</p>
|
||||
{!!entries.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="sr-only">
|
||||
Índice por etapa, versão e data
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Etapa</th>
|
||||
<th className="p-2">
|
||||
Relatório ou documento
|
||||
</th>
|
||||
<th className="p-2">Versão e data</th>
|
||||
<th className="p-2">Estado</th>
|
||||
<th className="p-2">Consultar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-t">
|
||||
<td className="p-2">
|
||||
{entry.stage}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{entry.title}
|
||||
</td>
|
||||
<td className="max-w-56 p-2 break-words">
|
||||
{entry.version} ·{' '}
|
||||
{entry.date ||
|
||||
'Data não registada'}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{entry.state}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a
|
||||
className="underline"
|
||||
href={entry.href}
|
||||
>
|
||||
{entry.action}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!entries.length && (
|
||||
<p>
|
||||
Nenhum relatório ou documento produzido e autorizado
|
||||
neste âmbito. O relatório consolidado identifica as
|
||||
etapas e as pendências existentes.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Os relatórios financeiros são apresentados apenas ao
|
||||
titular autorizado do rascunho. Consulte os documentos
|
||||
de cada acto antes de executar o investimento.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SimulationIrpcAnnual } from '@/components/simulation-irpc-annual';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import { SimulationIrpcNormal } from '@/components/simulation-irpc-normal';
|
||||
import { SimulationIrpcWithholdings } from '@/components/simulation-irpc-withholdings';
|
||||
import { SimulationIspcResiduals } from '@/components/simulation-ispc-residuals';
|
||||
@@ -3077,6 +3078,10 @@ export function SimulationForm() {
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<DemographicContext
|
||||
key={String(draft.district_id ?? draft.province_id ?? '')}
|
||||
territoryId={String(draft.district_id || draft.province_id || '')}
|
||||
/>
|
||||
<progress
|
||||
className="h-2 w-full accent-emerald-800"
|
||||
aria-label="Progresso do formulário"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import DemographicContext from './demographic-context';
|
||||
import TerritorialSources, {
|
||||
type TerritorialSourceEvidence,
|
||||
} from './territorial-source-evidence';
|
||||
@@ -95,6 +96,12 @@ export function LocationList({
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<DemographicContext
|
||||
key={location.id}
|
||||
territoryId={location.path
|
||||
.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id}
|
||||
/>
|
||||
{location.coordinates.map((point) => (
|
||||
<p key={point.geometry_id}>
|
||||
Coordenadas documentadas (
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
import { pathStates, type PathResult } from './investor-path.ts';
|
||||
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
|
||||
import { buildProjectDossierRows } from './project-dossier.ts';
|
||||
import type { SwotDraft } from './investment-preparation.ts';
|
||||
import type {
|
||||
DossierReportModel,
|
||||
DossierReportRow,
|
||||
} from './project-dossier-report.ts';
|
||||
|
||||
export type PreparationReport = {
|
||||
case_id: string;
|
||||
opportunity: string | null;
|
||||
version: number;
|
||||
context_revision: number;
|
||||
href: string;
|
||||
api_state: string;
|
||||
api_issues: string[];
|
||||
exports: {
|
||||
id: string;
|
||||
at: string;
|
||||
context_revision: number;
|
||||
fingerprint: string;
|
||||
historical: boolean;
|
||||
href: string;
|
||||
}[];
|
||||
external: {
|
||||
kind: string;
|
||||
date: string;
|
||||
reference: string;
|
||||
export_id: string;
|
||||
outcome?: string;
|
||||
}[];
|
||||
swot: SwotDraft;
|
||||
swot_review_required: boolean;
|
||||
};
|
||||
export type InvestorDossierReportInput = {
|
||||
case: { id: string; title: string; version: number };
|
||||
context_opportunity: string | null;
|
||||
opportunity_options: unknown[];
|
||||
investment_context: {
|
||||
revision: number;
|
||||
fields?: Record<string, string>;
|
||||
} | null;
|
||||
phases: Record<string, string>;
|
||||
steps: {
|
||||
code: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
version?: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
detail: {
|
||||
scope?: string;
|
||||
document_id?: string;
|
||||
authority?: string;
|
||||
reference?: string;
|
||||
decision_date?: string;
|
||||
recorded_at?: string;
|
||||
};
|
||||
}[];
|
||||
results: PathResult[];
|
||||
documents: {
|
||||
id: string;
|
||||
document_id?: string;
|
||||
file_name: string;
|
||||
status: string;
|
||||
malware_scan_status: string;
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
preparation_report?: PreparationReport | null;
|
||||
fiscal_reports?: {
|
||||
simulation_id: string;
|
||||
report_id: string;
|
||||
report_version_no?: number;
|
||||
report_sha256?: string;
|
||||
consented_at?: string;
|
||||
scope_state?: 'identified' | 'common' | 'unconfirmed';
|
||||
}[];
|
||||
land_report?: {
|
||||
id: string;
|
||||
version: number;
|
||||
updated_at: string | null;
|
||||
href: string;
|
||||
} | null;
|
||||
company_reports?: {
|
||||
comparison?: { revision: number; at: string };
|
||||
preparation?: { revision: number; at: string };
|
||||
guides?: Record<
|
||||
string,
|
||||
{ revision: number; at: string; slug: string; title: string }
|
||||
>;
|
||||
};
|
||||
};
|
||||
export type DossierReportIndexEntry = {
|
||||
id: string;
|
||||
stage: string;
|
||||
title: string;
|
||||
version: string;
|
||||
date: string;
|
||||
state: string;
|
||||
href: string;
|
||||
action: string;
|
||||
};
|
||||
const externalStates: Record<string, string> = {
|
||||
preparing: 'Em preparação',
|
||||
ready: 'Pronto para entrega',
|
||||
delivery_recorded: 'Entrega externa registada',
|
||||
decision_received: 'Decisão recebida',
|
||||
};
|
||||
const externalRecords: Record<string, string> = {
|
||||
delivery: 'Entrega externa',
|
||||
clarification: 'Pedido de esclarecimento',
|
||||
response: 'Resposta recebida',
|
||||
decision: 'Decisão recebida',
|
||||
};
|
||||
const decisions: Record<string, string> = {
|
||||
approved: 'Favorável',
|
||||
refused: 'Desfavorável',
|
||||
conditional: 'Condicionada',
|
||||
other: 'Outra decisão',
|
||||
};
|
||||
const dimensions = {
|
||||
strength: 'Forças',
|
||||
weakness: 'Fraquezas',
|
||||
opportunity: 'Oportunidades',
|
||||
threat: 'Ameaças',
|
||||
};
|
||||
const classifications = {
|
||||
documented_fact: 'Facto documentado',
|
||||
hypothesis: 'Hipótese',
|
||||
unconfirmed: 'Por confirmar',
|
||||
};
|
||||
|
||||
function checkScope(input: InvestorDossierReportInput): void {
|
||||
if (input.opportunity_options.length > 1 && !input.context_opportunity)
|
||||
throw new Error(
|
||||
'Seleccione a oportunidade antes de reunir os relatórios.',
|
||||
);
|
||||
const preparation = input.preparation_report;
|
||||
if (
|
||||
preparation &&
|
||||
(preparation.case_id !== input.case.id ||
|
||||
preparation.opportunity !== input.context_opportunity)
|
||||
)
|
||||
throw new Error(
|
||||
'A preparação pertence a outro dossier ou oportunidade.',
|
||||
);
|
||||
}
|
||||
|
||||
function documentsInDossier(input: InvestorDossierReportInput) {
|
||||
const seen = new Set<string>();
|
||||
return input.documents.filter((document) => {
|
||||
const key = `${document.document_id ?? document.id}:${document.version_no}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function investorDossierReportIndex(
|
||||
input: InvestorDossierReportInput,
|
||||
): DossierReportIndexEntry[] {
|
||||
checkScope(input);
|
||||
const base = `/investimentos/${encodeURIComponent(input.case.id)}`;
|
||||
const query = input.context_opportunity
|
||||
? `?opportunity=${encodeURIComponent(input.context_opportunity)}`
|
||||
: '';
|
||||
const entries: DossierReportIndexEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (entry: DossierReportIndexEntry) => {
|
||||
if (!seen.has(entry.id)) {
|
||||
seen.add(entry.id);
|
||||
entries.push(entry);
|
||||
}
|
||||
};
|
||||
for (const result of input.results) {
|
||||
if ((result.context_opportunity ?? null) !== input.context_opportunity)
|
||||
continue;
|
||||
add({
|
||||
id: `finance:${result.id}`,
|
||||
stage: 'S03',
|
||||
title: 'Resultado de viabilidade financeira',
|
||||
version: String(result.draft_version),
|
||||
date: result.recorded_at,
|
||||
state:
|
||||
result.context_state === 'review_required' ||
|
||||
result.context_revision !== input.investment_context?.revision
|
||||
? 'Histórico — requer revisão'
|
||||
: 'Resultado guardado',
|
||||
href: `${base}${query}#resultados-financeiros`,
|
||||
action: 'Consultar resultado',
|
||||
});
|
||||
}
|
||||
for (const source of input.fiscal_reports ?? [])
|
||||
add({
|
||||
id: `fiscal:${source.report_id}`,
|
||||
stage: 'S03',
|
||||
title: 'Relatório de simulação fiscal',
|
||||
version: String(source.report_version_no ?? 'Por identificar'),
|
||||
date: source.consented_at ?? '',
|
||||
state:
|
||||
source.scope_state === 'identified'
|
||||
? 'Versão preservada — estimativa fiscal'
|
||||
: source.scope_state === 'common'
|
||||
? 'Versão preservada — contexto comum do dossier'
|
||||
: 'Versão preservada — âmbito por confirmar',
|
||||
href: `${base}/relatorios/fiscais/${encodeURIComponent(source.report_id)}${query}`,
|
||||
action: 'Descarregar versão preservada (JSON)',
|
||||
});
|
||||
for (const kind of ['comparison', 'preparation'] as const) {
|
||||
const saved = input.company_reports?.[kind];
|
||||
if (saved)
|
||||
add({
|
||||
id: `company:${kind}`,
|
||||
stage: 'S05',
|
||||
title:
|
||||
kind === 'comparison'
|
||||
? 'Comparação de formas jurídicas'
|
||||
: 'Preparação da empresa',
|
||||
version: String(saved.revision),
|
||||
date: saved.at,
|
||||
state: 'Preparação guardada',
|
||||
href: `${base}/${kind === 'comparison' ? 'forma-juridica' : 'preparar-empresa'}`,
|
||||
action: 'Consultar preparação',
|
||||
});
|
||||
}
|
||||
const preparation = input.preparation_report;
|
||||
if (input.land_report) {
|
||||
add({
|
||||
id: `land:${input.land_report.id}`,
|
||||
stage: 'S10',
|
||||
title: 'Preparação de acesso à terra',
|
||||
version: String(input.land_report.version),
|
||||
date: input.land_report.updated_at ?? '',
|
||||
state: 'Preparação guardada — não é título DUAT',
|
||||
href: input.land_report.href,
|
||||
action: 'Abrir versão para impressão',
|
||||
});
|
||||
}
|
||||
for (const exported of preparation?.exports ?? [])
|
||||
add({
|
||||
id: `apiex:${exported.id}`,
|
||||
stage: 'S06',
|
||||
title: 'Dossier documental APIEX',
|
||||
version: exported.id,
|
||||
date: exported.at,
|
||||
state: exported.historical
|
||||
? 'Exportação histórica preservada'
|
||||
: 'Exportação preparada',
|
||||
href: exported.href,
|
||||
action: 'Descarregar pacote',
|
||||
});
|
||||
if (preparation?.swot.rows.length)
|
||||
add({
|
||||
id: 'swot',
|
||||
stage: 'S02',
|
||||
title: 'Análise SWOT',
|
||||
version: String(preparation.version),
|
||||
date: preparation.swot.review?.at ?? '',
|
||||
state: preparation.swot_review_required
|
||||
? 'Revisão necessária'
|
||||
: 'Revista pela equipa',
|
||||
href: preparation.href,
|
||||
action: 'Consultar análise',
|
||||
});
|
||||
for (const guide of Object.values(input.company_reports?.guides ?? {}))
|
||||
add({
|
||||
id: `guide:${guide.slug}`,
|
||||
stage: 'S06',
|
||||
title: guide.title,
|
||||
version: String(guide.revision),
|
||||
date: guide.at,
|
||||
state: 'Referência guardada para o dossier',
|
||||
href: `${base}/guias/${encodeURIComponent(guide.slug)}`,
|
||||
action: 'Consultar guia',
|
||||
});
|
||||
for (const document of documentsInDossier(input)) {
|
||||
const stages = input.steps.filter(
|
||||
(step) => step.detail.document_id === document.document_id,
|
||||
);
|
||||
add({
|
||||
id: `document:${document.id}`,
|
||||
stage:
|
||||
stages.map((step) => step.code).join(', ') || 'Anexos comuns',
|
||||
title: document.file_name,
|
||||
version: String(document.version_no),
|
||||
date: '',
|
||||
state:
|
||||
document.malware_scan_status === 'clean' &&
|
||||
document.status === 'active'
|
||||
? 'Documento associado'
|
||||
: 'Disponibilidade por conferir',
|
||||
href: `/balcao-virtual/dossiers/${encodeURIComponent(input.case.id)}/documentos/${encodeURIComponent(document.id)}`,
|
||||
action: 'Consultar documento',
|
||||
});
|
||||
}
|
||||
for (const step of input.steps) {
|
||||
if (
|
||||
entries.some((entry) => entry.stage.split(', ').includes(step.code))
|
||||
)
|
||||
continue;
|
||||
add({
|
||||
id: `stage:${step.code}`,
|
||||
stage: step.code,
|
||||
title: step.title,
|
||||
version: String(step.version ?? 'Não registada'),
|
||||
date: step.detail.recorded_at ?? '',
|
||||
state:
|
||||
step.state === 'dispensed'
|
||||
? 'Preparação dispensada — consultar fundamento'
|
||||
: 'Sem relatório ou documento associado',
|
||||
href: `${base}${query}#${step.code}`,
|
||||
action: 'Consultar etapa',
|
||||
});
|
||||
}
|
||||
return entries.sort((a, b) => a.stage.localeCompare(b.stage));
|
||||
}
|
||||
|
||||
export function createInvestorDossierReport(
|
||||
input: InvestorDossierReportInput,
|
||||
generatedAt = new Date().toISOString(),
|
||||
): DossierReportModel {
|
||||
const index = investorDossierReportIndex(input);
|
||||
const row = (
|
||||
field: string,
|
||||
value: string,
|
||||
origin = 'Registo guardado neste dossier',
|
||||
classification = 'Preparação do investimento',
|
||||
): DossierReportRow => ({ field, value, origin, classification });
|
||||
const finance: DossierReportRow[] = [];
|
||||
for (const result of input.results.filter(
|
||||
(item) =>
|
||||
(item.context_opportunity ?? null) === input.context_opportunity,
|
||||
)) {
|
||||
const reference = `Resultado ${result.id}; rascunho ${result.draft_id}, versão ${result.draft_version}; ${result.recorded_at}`;
|
||||
const state =
|
||||
result.context_revision === input.investment_context?.revision &&
|
||||
result.context_state !== 'review_required'
|
||||
? 'Estimativa guardada'
|
||||
: 'Histórico — rever antes de utilizar';
|
||||
try {
|
||||
if (
|
||||
result.engine !== 'financial-calculators:v1' ||
|
||||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
|
||||
)
|
||||
throw new Error('Versão não recuperável');
|
||||
const currency = result.currency ?? '';
|
||||
const baseline = new Set(
|
||||
buildProjectDossierRows({ currency }).map((item) =>
|
||||
JSON.stringify(item),
|
||||
),
|
||||
);
|
||||
const sections = new Set([
|
||||
'Plano financeiro',
|
||||
'Empréstimo educativo',
|
||||
'Ponto de equilíbrio',
|
||||
'Financiamento',
|
||||
'Orçamento',
|
||||
]);
|
||||
const rows = buildProjectDossierRows({
|
||||
currency,
|
||||
financial_tools_snapshot: result.financial_snapshot,
|
||||
});
|
||||
for (const item of rows.filter(
|
||||
(item) =>
|
||||
sections.has(item[0]) &&
|
||||
!baseline.has(JSON.stringify(item)),
|
||||
)) {
|
||||
finance.push(
|
||||
row(
|
||||
`${item[0]} — ${item[1]}`,
|
||||
item[2],
|
||||
`${reference}; ${item[3]}`,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
finance.push(
|
||||
row(
|
||||
'Resultado preservado',
|
||||
'Formato indisponível para cálculo; consulte a origem, sem substituir valores por zero.',
|
||||
reference,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const preparation = input.preparation_report;
|
||||
const swot = preparation?.swot;
|
||||
return {
|
||||
title: input.case.title,
|
||||
generatedAt,
|
||||
version: `Dossier ${input.case.id} · v${input.case.version} · contexto v${input.investment_context?.revision ?? 'não identificado'} · oportunidade ${input.context_opportunity ?? 'contexto comum'}`,
|
||||
references: [],
|
||||
limitations: [
|
||||
'Relatório privado de preparação; não comprova licenciamento, aprovação ou financiamento.',
|
||||
'Os actos externos só valem pelo respectivo documento e entidade. Pendências e versões históricas permanecem identificadas.',
|
||||
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Investimento e localização em preparação',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'Dados guardados no contexto seleccionado; não representam aprovação ou validação externa.',
|
||||
],
|
||||
rows: Object.entries({
|
||||
project_title: 'Projecto',
|
||||
project_summary: 'Descrição',
|
||||
sector: 'Sector',
|
||||
province: 'Província',
|
||||
district: 'Distrito',
|
||||
location: 'Localização',
|
||||
total_investment: 'Investimento previsto',
|
||||
currency: 'Moeda',
|
||||
expected_start_date: 'Início previsto',
|
||||
})
|
||||
.filter(([key]) =>
|
||||
input.investment_context?.fields?.[key]?.trim(),
|
||||
)
|
||||
.map(([key, label]) =>
|
||||
row(
|
||||
label,
|
||||
input.investment_context?.fields?.[key] ?? '',
|
||||
`Contexto v${input.investment_context?.revision ?? 'não identificado'}`,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Identificação e índice dos relatórios',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
`Dossier: ${input.case.id}`,
|
||||
`Oportunidade: ${input.context_opportunity ?? 'Contexto comum do dossier'}`,
|
||||
`Gerado em ${generatedAt}; versão do dossier ${input.case.version}.`,
|
||||
],
|
||||
rows: index.map((entry) =>
|
||||
row(
|
||||
`${entry.stage} — ${entry.title}`,
|
||||
`${entry.state}; versão ${entry.version}; ${entry.date || 'data não registada'}`,
|
||||
entry.href,
|
||||
entry.action,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'execution',
|
||||
title: 'Etapas, resultados e pendências',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'A situação de uma etapa não significa que o respectivo relatório ou acto externo tenha sido produzido.',
|
||||
],
|
||||
rows: input.steps.map((step) =>
|
||||
row(
|
||||
`${step.code} — ${step.title}`,
|
||||
`${pathStates[step.state] ?? step.state}; versão ${step.version ?? 'não registada'}. ${step.reason || 'Sem fundamento adicional registado.'} ${step.detail.authority ?? ''} ${step.detail.reference ?? ''} ${step.detail.decision_date ?? ''}`,
|
||||
`Fase ${step.phase}: ${input.phases[String(step.phase)] ?? ''}`,
|
||||
step.state === 'not_started'
|
||||
? 'Pendente'
|
||||
: 'Estado guardado da etapa',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: 'Viabilidade e relatórios fiscais',
|
||||
private: true,
|
||||
paragraphs: finance.length
|
||||
? [
|
||||
'Resultados recuperados das entradas guardadas pelo motor existente; impostos destinam-se à simulação de viabilidade.',
|
||||
]
|
||||
: [
|
||||
'Nenhum resultado financeiro devolvido e autorizado neste âmbito. Não foi gerado um valor em falta.',
|
||||
],
|
||||
rows: [
|
||||
...finance,
|
||||
...(input.fiscal_reports ?? []).map((source) =>
|
||||
row(
|
||||
`Relatório fiscal ${source.report_id}`,
|
||||
`Versão ${source.report_version_no ?? 'não indicada'}; SHA256 ${source.report_sha256 ?? 'não indicado'}`,
|
||||
`/investimentos/${input.case.id}/relatorios/fiscais/${source.report_id}${input.context_opportunity ? `?opportunity=${encodeURIComponent(input.context_opportunity)}` : ''}`,
|
||||
source.scope_state === 'identified'
|
||||
? 'Versão fiscal preservada'
|
||||
: source.scope_state === 'common'
|
||||
? 'Versão preservada no contexto comum do dossier'
|
||||
: 'Origem preservada no dossier; âmbito por confirmar',
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'market',
|
||||
title: 'Análise SWOT',
|
||||
private: true,
|
||||
paragraphs: swot?.rows.length
|
||||
? [
|
||||
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
|
||||
]
|
||||
: ['Análise SWOT ainda não produzida neste âmbito.'],
|
||||
rows: (swot?.rows ?? []).map((item) =>
|
||||
row(
|
||||
`${dimensions[item.dimension]} — ${item.text}`,
|
||||
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
|
||||
item.reference || 'Fonte por confirmar',
|
||||
classifications[item.classification],
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
title: 'Preparação documental APIEX e actos externos',
|
||||
private: true,
|
||||
paragraphs: preparation
|
||||
? [
|
||||
`Estado: ${externalStates[preparation.api_state] ?? preparation.api_state}. Exportar não equivale a entregar; entrega não equivale a aprovação.`,
|
||||
...preparation.api_issues,
|
||||
]
|
||||
: ['Preparação APIEX ainda não disponível neste âmbito.'],
|
||||
rows: (preparation?.external ?? []).map((item) =>
|
||||
row(
|
||||
`${externalRecords[item.kind] ?? item.kind} — ${item.date}`,
|
||||
`${item.reference}; ${item.outcome ? (decisions[item.outcome] ?? item.outcome) : 'sem decisão registada'}`,
|
||||
`Exportação ${item.export_id}`,
|
||||
'Registo de entrega ou resposta externa',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
title: 'Documentos associados ao dossier',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'Consulte os originais no dossier. Os controlos de acesso e disponibilidade mantêm-se em cada descarga.',
|
||||
'Documentos comuns não comprovam a sua aplicabilidade a todas as oportunidades; confira a associação à etapa e ao pedido.',
|
||||
],
|
||||
rows: documentsInDossier(input).map((document) =>
|
||||
row(
|
||||
document.file_name,
|
||||
`Versão ${document.version_no}; SHA256 ${document.sha256}`,
|
||||
`/balcao-virtual/dossiers/${input.case.id}/documentos/${document.id}`,
|
||||
document.status === 'active' &&
|
||||
document.malware_scan_status === 'clean'
|
||||
? 'Documento associado'
|
||||
: 'Disponibilidade por conferir',
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
: '',
|
||||
};
|
||||
}
|
||||
@@ -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]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(' ') ?? '';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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: {
|
||||
@@ -59,6 +61,21 @@ export default function Dashboard({
|
||||
<ProfileNavigation />
|
||||
<ContentManagementEntry />
|
||||
<DashboardWorkQueue queue={workQueue} />
|
||||
{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
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
persistOpportunitySelection,
|
||||
} from '@/lib/opportunity-selection';
|
||||
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import OpportunityCorridorGroups, {
|
||||
type CorridorGroup,
|
||||
} from '@/components/opportunity-corridor-groups';
|
||||
@@ -22,7 +23,7 @@ import type { QuantitativeMapContext } from '@/components/investment-map-indicat
|
||||
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
|
||||
import { type TerritoryOption } from '@/components/investment-territory';
|
||||
import { Frame, button, card, money, api } from './shared';
|
||||
import type { Opportunity, References } from './shared';
|
||||
import type { Opportunity, References, Context } from './shared';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bookmark,
|
||||
@@ -171,7 +172,9 @@ export default function Catalogue({
|
||||
const [territoryPath, setTerritoryPath] = useState(territorySelection);
|
||||
const [saved, setSaved] = useState(favourites);
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
|
||||
const [comparison, setComparison] = useState<
|
||||
{ record: Opportunity; context?: Context }[]
|
||||
>([]);
|
||||
const comparisonRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (view === 'corridors' && comparison.length)
|
||||
@@ -406,6 +409,21 @@ export default function Catalogue({
|
||||
: 'Catálogo de investimento'
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
'/localizacoes/demografia' +
|
||||
(territoryPath
|
||||
.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id
|
||||
? '?territories[]=' + territoryPath
|
||||
.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id
|
||||
: '')
|
||||
}
|
||||
>
|
||||
Comparar demografia e evolução das localizações
|
||||
</Link>
|
||||
{!!territoryPath.length && (
|
||||
<Link
|
||||
className={button}
|
||||
@@ -1074,6 +1092,19 @@ export default function Catalogue({
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<th className="border p-3">Demografia INE</th>
|
||||
{comparison.map(({ record: r, context: c }) => (
|
||||
<td className="border p-3" key={r.id}>
|
||||
<DemographicContext
|
||||
key={r.id}
|
||||
territoryId={c?.primary_territory_path
|
||||
?.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import DemographicEvidence, {
|
||||
type DemographicAnalysis,
|
||||
} from '@/components/demographic-evidence';
|
||||
import { Frame, button } from './shared';
|
||||
|
||||
export default function Demography({
|
||||
evidence,
|
||||
filters,
|
||||
territories,
|
||||
sectors,
|
||||
}: {
|
||||
evidence: DemographicAnalysis;
|
||||
filters: {
|
||||
territories?: string[];
|
||||
start_year?: number;
|
||||
end_year?: number;
|
||||
sector?: string;
|
||||
};
|
||||
territories: {
|
||||
id: string;
|
||||
name: string;
|
||||
location_type: string;
|
||||
parent_id: string | null;
|
||||
}[];
|
||||
sectors: Record<string, string>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(filters.territories ?? []);
|
||||
const [start, setStart] = useState(
|
||||
filters.start_year ?? new Date().getFullYear(),
|
||||
);
|
||||
const [end, setEnd] = useState(filters.end_year ?? start);
|
||||
const [sector, setSector] = useState(filters.sector ?? 'general');
|
||||
return (
|
||||
<Frame title="Demografia e viabilidade territorial">
|
||||
<p>
|
||||
Compare até quatro localizações no mesmo sector e período. Use
|
||||
esta evidência em conjunto com as condições económicas, fiscais
|
||||
e territoriais do investimento.
|
||||
</p>
|
||||
<form
|
||||
className="grid gap-4 rounded border bg-white p-4 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
router.get(
|
||||
'/localizacoes/demografia',
|
||||
{
|
||||
territories: selected,
|
||||
start_year: start,
|
||||
end_year: end,
|
||||
sector,
|
||||
},
|
||||
{ preserveState: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Províncias ou distritos (até quatro)
|
||||
<select
|
||||
multiple
|
||||
className="block h-48 w-full rounded border p-2"
|
||||
value={selected}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
Array.from(
|
||||
e.target.selectedOptions,
|
||||
(o) => o.value,
|
||||
).slice(0, 4),
|
||||
)
|
||||
}
|
||||
>
|
||||
{territories.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} —{' '}
|
||||
{t.location_type === 'province'
|
||||
? 'Província'
|
||||
: `Distrito (${territories.find((p) => p.id === t.parent_id)?.name ?? ''})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
Sector
|
||||
<select
|
||||
className="block w-full rounded border p-2"
|
||||
value={sector}
|
||||
onChange={(e) => setSector(e.target.value)}
|
||||
>
|
||||
{Object.entries(sectors).map(([key, label]) => (
|
||||
<option value={key} key={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Ano inicial
|
||||
<input
|
||||
className="block rounded border p-2"
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={start}
|
||||
onChange={(e) => setStart(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Ano final
|
||||
<input
|
||||
className="block rounded border p-2"
|
||||
type="number"
|
||||
min={start}
|
||||
max={Math.min(2100, start + 50)}
|
||||
value={end}
|
||||
onChange={(e) => setEnd(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button className={button} disabled={selected.length === 0}>
|
||||
Consultar e comparar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<DemographicEvidence evidence={evidence} />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProactiveSummary } from '@/components/proactive-investment';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import EducationalGuides from '@/components/educational-guides';
|
||||
import InvestmentComplements, {
|
||||
type ComplementOption,
|
||||
@@ -183,6 +184,12 @@ export default function Detail({
|
||||
filters={{ opportunity_id: r.id }}
|
||||
territoryId={c.primary_territory_path?.at(-1)?.id}
|
||||
/>
|
||||
<DemographicContext
|
||||
key={c.primary_territory_path?.at(-1)?.id}
|
||||
territoryId={c.primary_territory_path
|
||||
?.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id}
|
||||
/>
|
||||
{c.primary_territory_path &&
|
||||
c.primary_territory_path.length > 0 && (
|
||||
<p>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
|
||||
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
|
||||
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
financialCategories,
|
||||
financialTypes,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
} from '@/lib/investor-path';
|
||||
|
||||
type Step = {
|
||||
version: number;
|
||||
code: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
@@ -45,6 +48,10 @@ type Step = {
|
||||
source_paragraph: number;
|
||||
};
|
||||
type Path = {
|
||||
preparation_report?: InvestorDossierReportInput['preparation_report'];
|
||||
company_reports?: InvestorDossierReportInput['company_reports'];
|
||||
fiscal_reports?: InvestorDossierReportInput['fiscal_reports'];
|
||||
land_report?: InvestorDossierReportInput['land_report'];
|
||||
sector_references?: SectorResourceSelection[];
|
||||
funding_selections?: {
|
||||
id: string;
|
||||
@@ -117,6 +124,8 @@ type Path = {
|
||||
file_name: string;
|
||||
status: string;
|
||||
malware_scan_status: string;
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
requests: {
|
||||
id: string;
|
||||
@@ -995,7 +1004,8 @@ export default function InvestmentPath(path: Path) {
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<section className="space-y-3">
|
||||
<InvestorDossierReports dossier={path} />
|
||||
<section id="resultados-financeiros" className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Resultados devolvidos ao dossier
|
||||
</h2>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { Management, button, control } from './shared';
|
||||
|
||||
type Plan = {
|
||||
mode: 'initial' | 'remaining' | 'zambezia';
|
||||
pending_count: number;
|
||||
aliases: Record<string,string>;
|
||||
alias_sources: string[];
|
||||
missing_province_source: string | null;
|
||||
hash: string;
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
consistent: boolean;
|
||||
backup_verified: boolean;
|
||||
mfa_confirmed: boolean;
|
||||
target: { database: string; port: number };
|
||||
migration: string;
|
||||
code_sha256: string;
|
||||
files: Record<string, { sha256: string; records: number; issues?: Record<string,number> }>;
|
||||
new_districts: string[];
|
||||
notice: string;
|
||||
};
|
||||
|
||||
export default function DemographyInstallation({ plan }: { plan: Plan }) {
|
||||
const [reason, setReason] = useState(
|
||||
'Disponibilização autorizada pela Tempo em 13/11/2026, preservando valores, advertências e histórico.',
|
||||
);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [operation] = useState(createOperationKey);
|
||||
async function apply() {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')
|
||||
.slice(1)
|
||||
.join('=') ?? '';
|
||||
const response = await fetch('/management/demography/install', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(cookie),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hash: plan.hash,
|
||||
mode: plan.mode,
|
||||
confirm: confirmed,
|
||||
operation_key: operation,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!body.trim() || !response.headers.get('content-type')?.includes('application/json')) {
|
||||
throw new Error(`O servidor não devolveu um recibo JSON (HTTP ${response.status}). Consulte o estado antes de repetir a operação.`);
|
||||
}
|
||||
const result = JSON.parse(body);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
result.message ??
|
||||
'Não foi possível disponibilizar os dados.',
|
||||
);
|
||||
router.visit('/management/demography');
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Falha de comunicação. Consulte o estado antes de repetir.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Management title="Disponibilizar dados demográficos INE">
|
||||
<p>
|
||||
Destino:{' '}
|
||||
<strong>
|
||||
{plan.target.database}:{plan.target.port}
|
||||
</strong>
|
||||
. {plan.mode !== 'initial' ? 'Disponibiliza os ficheiros pendentes deste conjunto e acrescenta apenas os distritos autorizados, preservando todos os lotes já aprovados.' : 'Instala as tabelas demográficas e disponibiliza os três exemplares homologados.'} Mantém os dados existentes, as versões e os valores originais.
|
||||
</p>
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<dt>Backup e restauro da entrega</dt>
|
||||
<dd>
|
||||
{plan.backup_verified
|
||||
? 'Conferidos'
|
||||
: 'Pendentes ou desactualizados'}
|
||||
</dd>
|
||||
<dt>Estrutura</dt>
|
||||
<dd>
|
||||
{plan.consistent
|
||||
? plan.installed
|
||||
? 'Instalada'
|
||||
: 'Preparada'
|
||||
: 'Divergente — reconciliar'}
|
||||
</dd>
|
||||
<dt>MFA da sessão</dt>
|
||||
<dd>
|
||||
{plan.mfa_confirmed
|
||||
? 'Confirmado'
|
||||
: 'Conclua o MFA da sua conta antes de executar'}
|
||||
</dd>
|
||||
</dl>
|
||||
<table className="w-full text-left">
|
||||
<caption className="text-left font-semibold">
|
||||
Pré-visualização aprovada para disponibilização
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Exemplar</th>
|
||||
<th>Observações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(plan.files).map(([name, file]) => (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td>{file.records.toLocaleString('pt-MZ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Distritos a acrescentar quando ausentes:{' '}
|
||||
{plan.new_districts.join(' e ')}. Não são criados limites
|
||||
geográficos, licenças ou direitos territoriais.
|
||||
</p>
|
||||
<p>{plan.notice}</p>
|
||||
{plan.mode !== 'initial' && <details><summary>Conferir advertências por ficheiro</summary><p>Nas células com fórmulas, são conservados apenas os valores numéricos guardados no original. O portal não executa nem recalcula fórmulas. Valores ausentes não se tornam zero.</p><ul>{Object.entries(plan.files).map(([name,file])=><li key={name}><strong>{name}</strong><ul>{Object.entries(file.issues ?? {}).map(([code,count])=><li key={code}>{({cached_formula_value:'Valores guardados em células com fórmula',missing_value:'Valores em falta',age_total_mismatch:'Divergências na soma de idades',sex_total_mismatch:'Divergências na soma por sexo',residence_total_mismatch:'Divergências entre urbano/rural',district_total_mismatch:'Divergências na soma de distritos'} as Record<string,string>)[code] ?? code}: {count.toLocaleString('pt-MZ')}</li>)}</ul></li>)}</ul></details>}
|
||||
{plan.mode !== 'initial' && <p>{plan.pending_count} ficheiros ainda por disponibilizar. {plan.missing_province_source && <>{plan.missing_province_source}: ficheiro provincial não fornecido; não se presume cobertura anual.</>}</p>}
|
||||
{Object.keys(plan.aliases).length > 0 && <details><summary>Conferir correspondências com o cadastro existente</summary><ul>{Object.entries(plan.aliases).map(([key,name])=><li key={key}>{key.split('|').slice(0,2).join(' — ')} → {name}</li>)}</ul><p>Designações originais preservadas; os identificadores do cadastro não são renomeados ou fundidos.</p><ul>{plan.alias_sources.map(url=><li key={url}><a href={url} target="_blank" rel="noopener noreferrer">Consultar fundamento da correspondência</a></li>)}</ul></details>}
|
||||
{plan.pending_count === 0 && <p role="status">Todos os ficheiros deste conjunto já estão aprovados. Não é necessário repetir a operação.</p>}
|
||||
<label className="block">
|
||||
Motivo
|
||||
<textarea
|
||||
className={control}
|
||||
value={reason}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
Confirmo o destino, os {Object.keys(plan.files).length} exemplares, as correspondências territoriais e a preservação das advertências apresentadas.
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
!plan.ready ||
|
||||
!plan.mfa_confirmed ||
|
||||
!confirmed ||
|
||||
reason.length < 20 ||
|
||||
busy
|
||||
}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{busy ? 'A importar e conferir…' : plan.mode === 'zambezia' ? 'Disponibilizar Zambézia' : plan.mode !== 'initial' ? 'Disponibilizar restantes províncias' : 'Instalar e disponibilizar'}
|
||||
</button>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
<p>
|
||||
<a className="underline" href="/localizacoes/demografia">
|
||||
Consultar os dados disponíveis
|
||||
</a>
|
||||
</p>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Link, router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Management, button, control } from './shared';
|
||||
|
||||
type Preview = {
|
||||
id: string;
|
||||
file_name: string;
|
||||
sha256: string;
|
||||
publication_version: string;
|
||||
status: string;
|
||||
report: {
|
||||
record_count: number;
|
||||
issue_counts: Record<string, number>;
|
||||
issues: { code: string; sheet: string; cell: string; delta?: string }[];
|
||||
};
|
||||
matches: {
|
||||
key: string;
|
||||
province: string;
|
||||
territory: string;
|
||||
level: string;
|
||||
geo_location_id: string | null;
|
||||
state: string;
|
||||
}[];
|
||||
territory_options: {
|
||||
id: string;
|
||||
name: string;
|
||||
location_type: string;
|
||||
parent_id: string | null;
|
||||
}[];
|
||||
events: {
|
||||
id: number;
|
||||
action: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
};
|
||||
export default function DemographyManagement({
|
||||
batches,
|
||||
preview,
|
||||
qa = true,
|
||||
editorialEnabled = false,
|
||||
editorialRevision = 0,
|
||||
}: {
|
||||
editorialEnabled?: boolean;
|
||||
editorialRevision?: number;
|
||||
qa?: boolean;
|
||||
batches: {
|
||||
id: string;
|
||||
file_name: string;
|
||||
publication_version: string;
|
||||
status: string;
|
||||
}[];
|
||||
preview: Preview | null;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [version, setVersion] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
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('');
|
||||
try {
|
||||
const token =
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')
|
||||
.slice(1)
|
||||
.join('=') ?? '';
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(token),
|
||||
...(body instanceof FormData
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||
});
|
||||
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.',
|
||||
);
|
||||
setMapping({});
|
||||
router.get('/management/demography', { batch: data.id });
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error ? error.message : 'Erro de comunicação.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Management
|
||||
title={
|
||||
qa ? 'Dados demográficos INE — QA' : 'Dados demográficos INE'
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Preparar não grava observações. Confirmar importa para consulta
|
||||
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"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
body.append('publication_version', version);
|
||||
if (year) body.append('baseline_year', year);
|
||||
void execute('/management/demography/prepare', body);
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
Excel original
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
required
|
||||
className={control}
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Publicação / identificação do exemplar
|
||||
<input
|
||||
required
|
||||
maxLength={255}
|
||||
className={control}
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Ano de referência, apenas para folha sem ano
|
||||
<input
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
className={control}
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className={button} disabled={busy}>
|
||||
Preparar e verificar
|
||||
</button>
|
||||
</form>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
<h2 className="text-xl font-semibold">Importações anteriores</h2>
|
||||
<ul>
|
||||
{batches.map((b) => (
|
||||
<li key={b.id}>
|
||||
<a
|
||||
className="underline"
|
||||
href={`/management/demography?batch=${b.id}`}
|
||||
>
|
||||
{b.file_name} · {b.publication_version} · {b.status}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{preview && (
|
||||
<section key={preview.id} className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{preview.file_name} — {preview.status}
|
||||
</h2>
|
||||
<p>
|
||||
{preview.report.record_count} observações extraídas.
|
||||
SHA256: {preview.sha256}
|
||||
</p>
|
||||
<details>
|
||||
<summary>Erros e advertências</summary>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
preview.report.issue_counts,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<ul>
|
||||
{preview.report.issues.map((i, n) => (
|
||||
<li key={n}>
|
||||
{i.sheet}!{i.cell}: {i.code}{' '}
|
||||
{i.delta ?? ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<h3 className="font-semibold">
|
||||
Correspondências territoriais
|
||||
</h3>
|
||||
{preview.matches.map((m) => (
|
||||
<label className="block" key={m.key}>
|
||||
{m.province} / {m.territory} ({m.level}) — {m.state}
|
||||
<select
|
||||
className={control}
|
||||
disabled={preview.status !== 'prepared'}
|
||||
value={
|
||||
mapping[m.key] ?? m.geo_location_id ?? ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
setMapping({
|
||||
...mapping,
|
||||
[m.key]: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Por resolver</option>
|
||||
{preview.territory_options
|
||||
.filter((t) => t.location_type === m.level)
|
||||
.map((t) => (
|
||||
<option value={t.id} key={t.id}>
|
||||
{t.name} (
|
||||
{preview.territory_options.find(
|
||||
(p) => p.id === t.parent_id,
|
||||
)?.name ?? 'Província'}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
Motivo e tratamento das advertências
|
||||
<textarea
|
||||
className={control}
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{preview.status === 'prepared' ? (
|
||||
<button
|
||||
disabled={busy || reason.length < 10}
|
||||
className={button}
|
||||
onClick={() =>
|
||||
void execute(
|
||||
`/management/demography/${preview.id}/confirm`,
|
||||
{
|
||||
sha256: preview.sha256,
|
||||
mapping,
|
||||
reason,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Confirmar importação
|
||||
</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>
|
||||
<details>
|
||||
<summary>Histórico</summary>
|
||||
<ul>
|
||||
{preview.events.map((e) => (
|
||||
<li key={e.id}>
|
||||
{e.created_at}: {e.action} — {e.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Management, Field, button, control } from './shared';
|
||||
import { states } from './editorial-workflow';
|
||||
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 './editorial-workflow';
|
||||
|
||||
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,267 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Field, Management, button, control } from './shared';
|
||||
|
||||
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',
|
||||
};
|
||||
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) })}`;
|
||||
return (
|
||||
<Management title="Circuito editorial">
|
||||
<p>
|
||||
Artigos, legislação e documentos: edições privadas e versão
|
||||
pública separadas. As oportunidades mantêm o seu circuito
|
||||
próprio.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{zonesUrl && (
|
||||
<Link className="underline" href={zonesUrl}>
|
||||
Zonas e indicadores: {nativePending.zones ?? 0}{' '}
|
||||
pendentes
|
||||
</Link>
|
||||
)}
|
||||
{subjectsUrl && (
|
||||
<Link className="underline" href={subjectsUrl}>
|
||||
Estruturas e relações: {subjectsPending} pendentes
|
||||
</Link>
|
||||
)}
|
||||
{demographyUrl && (
|
||||
<Link className="underline" href={demographyUrl}>
|
||||
Demografia: {demographyPending} lotes pendentes
|
||||
</Link>
|
||||
)}
|
||||
{territorialUrl && (
|
||||
<Link className="underline" href={territorialUrl}>
|
||||
Conteúdos territoriais: {nativePending.territories}{' '}
|
||||
pendentes
|
||||
</Link>
|
||||
)}
|
||||
{portfolioUrl && (
|
||||
<Link className="underline" href={portfolioUrl}>
|
||||
Carteira: {portfolioPending} fichas pendentes
|
||||
</Link>
|
||||
)}
|
||||
{clustersUrl && (
|
||||
<Link className="underline" href={clustersUrl}>
|
||||
Competitividade dos clusters: {nativePending.clusters}{' '}
|
||||
pendentes
|
||||
</Link>
|
||||
)}
|
||||
<Link className="underline" href="/dashboard">
|
||||
Painel geral
|
||||
</Link>
|
||||
{resourceAccess && (
|
||||
<Link
|
||||
className="underline"
|
||||
href="/management/portal-services"
|
||||
>
|
||||
Biblioteca e editores
|
||||
</Link>
|
||||
)}
|
||||
{opportunitiesUrl && (
|
||||
<Link className="underline" href={opportunitiesUrl}>
|
||||
Oportunidades
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{opportunitiesUrl && (
|
||||
<section
|
||||
aria-label="Circuito de oportunidades"
|
||||
className="my-4 rounded border p-4"
|
||||
>
|
||||
<h2 className="font-semibold">Oportunidades</h2>
|
||||
<p>
|
||||
{opportunityPending} conteúdos pendentes no seu âmbito
|
||||
de acesso.
|
||||
</p>
|
||||
<Link className="underline" href={opportunitiesUrl}>
|
||||
Continuar na fila de oportunidades existente
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
{resourceAccess && (
|
||||
<>
|
||||
<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 aria-label="Notificações editoriais">
|
||||
<h2 className="font-semibold">Notificações</h2>
|
||||
{notices.map((n) => (
|
||||
<p key={n.id}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export function Management({
|
||||
Revisão da origem das oportunidades
|
||||
</Link>
|
||||
<Link href="/management/history">Histórico</Link>
|
||||
<Link href="/management/demography">Demografia INE</Link>
|
||||
<Link href="/management/crm/leads">CRM</Link>
|
||||
<Link href="/management/crm/investors">Investidores</Link>
|
||||
<Link href="/management/projects">Projectos</Link>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Force,
|
||||
} from '@/lib/cluster-competitiveness';
|
||||
import { button, control } from '../management/shared';
|
||||
import { states as editorialStates } from '../management/editorial-workflow';
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
) ?? '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 '../management/editorial-workflow';
|
||||
|
||||
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>
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\DemographyController;
|
||||
use App\Http\Middleware\PrivateInvestmentResponse;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/localizacoes/demografia', [DemographyController::class, 'index'])->middleware('throttle:30,1');
|
||||
Route::get('/api/v1/investment/demography', [DemographyController::class, 'index'])->middleware('throttle:30,1');
|
||||
// Each action enforces its existing installer/importer authorisation. The legacy
|
||||
// catalogue gate does not recognise a designated profile superuser.
|
||||
Route::prefix('management/demography')->middleware(['auth', 'verified', PrivateInvestmentResponse::class, 'throttle:20,1'])->group(function () {
|
||||
Route::get('/install', [DemographyController::class, 'installation']);
|
||||
Route::post('/install', [DemographyController::class, 'install']);
|
||||
Route::get('/', [DemographyController::class, 'management']);
|
||||
Route::post('/prepare', [DemographyController::class, 'prepare']);
|
||||
Route::post('/{id}/confirm', [DemographyController::class, 'confirm'])->whereUuid('id');
|
||||
Route::post('/{id}/transition', [DemographyController::class, 'transition'])->whereUuid('id');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user