diff --git a/app/Http/Controllers/DemographyController.php b/app/Http/Controllers/DemographyController.php
index c7dff792..4060a4a9 100644
--- a/app/Http/Controllers/DemographyController.php
+++ b/app/Http/Controllers/DemographyController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
+use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -40,7 +41,10 @@ final class DemographyController extends Controller
$service->authorize($request->user());
$data = $request->validate(['batch' => 'nullable|uuid']);
- return Inertia::render('management/demography', ['qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
+ $workflow = app(PortalEditorialWorkflow::class);
+ $editorial = $workflow->demographyAvailable();
+
+ return Inertia::render('management/demography', ['editorialEnabled' => $editorial, 'editorialRevision' => $editorial && isset($data['batch']) ? (int) ($workflow->latestDemography($data['batch'])->revision_no ?? 0) : 0, 'qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
}
diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php
index b1294309..9aead37b 100644
--- a/app/Http/Controllers/PortalEditorialWorkflowController.php
+++ b/app/Http/Controllers/PortalEditorialWorkflowController.php
@@ -7,8 +7,10 @@ use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialWorkflow;
use App\Services\VerifiedDocumentBinary;
+use App\Support\CheckedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@@ -41,7 +43,7 @@ final class PortalEditorialWorkflowController extends Controller
$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)];
+ 'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
@@ -105,6 +107,53 @@ final class PortalEditorialWorkflowController extends Controller
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);
diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php
index 4e4ed0a1..5535545f 100644
--- a/app/Services/ContentManagement.php
+++ b/app/Services/ContentManagement.php
@@ -24,6 +24,9 @@ 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'];
}
diff --git a/app/Services/DemographicImport.php b/app/Services/DemographicImport.php
index 6121ade5..733dbacf 100644
--- a/app/Services/DemographicImport.php
+++ b/app/Services/DemographicImport.php
@@ -7,6 +7,7 @@ use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
@@ -15,26 +16,37 @@ final class DemographicImport
{
/**
* CLI preparation is read-only input, never an import or approval.
+ *
* @return array{preview:string,records:string,receipt:array}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
- if (! is_string($root) || $root === '') { return null; }
+ if (! is_string($root) || $root === '') {
+ return null;
+ }
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
- if (! is_file($directory.'/preparation.json')) { return null; }
+ if (! is_file($directory.'/preparation.json')) {
+ return null;
+ }
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
- try { $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR); }
- catch (\JsonException) { abort(409, 'Recibo CLI inválido.'); }
+ try {
+ $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR);
+ } catch (\JsonException) {
+ abort(409, 'Recibo CLI inválido.');
+ }
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
- try { $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR); }
- catch (\JsonException) { abort(409, 'Prévia CLI inválida.'); }
+ try {
+ $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR);
+ } catch (\JsonException) {
+ abort(409, 'Prévia CLI inválida.');
+ }
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
@@ -49,10 +61,15 @@ final class DemographicImport
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
- if ($source === null || $source['receipt']['record_count'] !== $expected['records']) { return false; }
+ if ($source === null || $source['receipt']['record_count'] !== $expected['records']) {
+ return false;
+ }
}
+
return $files !== [];
- } catch (\Throwable) { return false; }
+ } catch (\Throwable) {
+ return false;
+ }
}
public function authorize(User $actor): void
@@ -164,6 +181,7 @@ final class DemographicImport
private function literal(string $name): string
{
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
+
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
}
@@ -228,13 +246,16 @@ final class DemographicImport
{
if (! isset($report['records_file_sha256'])) {
yield from $report['records'];
+
return;
}
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
$stream = new \SplFileObject($path, 'r');
while (! $stream->eof()) {
$line = $stream->fgets();
- if (trim($line) === '') { continue; }
+ if (trim($line) === '') {
+ continue;
+ }
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
}
@@ -246,6 +267,8 @@ final class DemographicImport
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch !== null, 404);
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
+ abort_if($action === 'approved' && $batch->status !== 'approved' && app(PortalEditorialWorkflow::class)->demographyWrites($id), 409, 'Use a aprovação e publicação separadas do lote no circuito editorial.');
+ abort_if($action === 'disabled' && Schema::hasColumn('portal_editorial_editions', 'demographic_import_id') && DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->exists(), 409, 'Arquive a edição no circuito editorial para retirar a sua publicação.');
// No deletes, replacement of other editions, or automatic publication in production.
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
diff --git a/app/Services/PortalEditorialWorkflow.php b/app/Services/PortalEditorialWorkflow.php
index 71e23d69..2469ace1 100644
--- a/app/Services/PortalEditorialWorkflow.php
+++ b/app/Services/PortalEditorialWorkflow.php
@@ -8,12 +8,14 @@ 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;
@@ -70,7 +72,7 @@ final class PortalEditorialWorkflow
try {
return hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $p)));
} catch (HttpException $exception) {
- if (in_array($exception->getStatusCode(), [404, 422], true)) {
+ if (in_array($exception->getStatusCode(), [404, 409, 422], true)) {
return false;
}
throw $exception;
@@ -166,7 +168,12 @@ final class PortalEditorialWorkflow
return false;
}
try {
- if ($edition->dossier_id ?? null) {
+ if ($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;
}
@@ -194,6 +201,9 @@ final class PortalEditorialWorkflow
public function requiresIndependent(\stdClass $edition): bool
{
$p = $this->payload($edition);
+ 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']);
}
@@ -212,7 +222,7 @@ final class PortalEditorialWorkflow
}
$edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null;
$result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
- 'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id).'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no];
+ 'href' => $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;
}
@@ -241,7 +251,7 @@ final class PortalEditorialWorkflow
return null;
}
- return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor), 'unread' => $this->unreadCount($actor)];
+ return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor) + $this->demographyPending($actor), 'unread' => $this->unreadCount($actor)];
}
/** @param array $resource
@@ -440,10 +450,143 @@ final class PortalEditorialWorkflow
*/
private function refreshPayload(\stdClass $edition, array $payload): array
{
+ 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 $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 $changes */
public function savePortfolio(User $actor, InvestmentDossier $record, int $expected, array $changes): \stdClass
{
@@ -554,21 +697,27 @@ final class PortalEditorialWorkflow
}
/** @param array $input */
- public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false): \stdClass
+ public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false): \stdClass
{
- if ($portfolio) {
+ abort_if($portfolio && $demography, 422);
+ if ($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) {
- $r = DB::table($portfolio ? 'investment_dossiers' : 'portal_resources')->where('id', $resource)->lockForUpdate()->first();
- $e = DB::table('portal_editorial_editions')->where('id', $id)->where($portfolio ? 'dossier_id' : 'resource_id', $resource)->lockForUpdate()->first();
+ return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio, $demography) {
+ $r = DB::table($demography ? 'demographic_imports' : ($portfolio ? 'investment_dossiers' : 'portal_resources'))->where('id', $resource)->lockForUpdate()->first();
+ $e = DB::table('portal_editorial_editions')->where('id', $id)->where($demography ? 'demographic_import_id' : ($portfolio ? 'dossier_id' : 'resource_id'), $resource)->lockForUpdate()->first();
abort_unless($r && $e, 404);
- $latest = $portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource);
+ $latest = $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)) {
@@ -611,7 +760,9 @@ final class PortalEditorialWorkflow
if ($e->legal_required) {
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
- if ($portfolio) {
+ if ($demography) {
+ $this->publishDemography($actor, $r, $e, $payload, $data['reason']);
+ } elseif ($portfolio) {
$this->publishPortfolio($actor, $r, $e, $payload);
} else {
$this->publish($actor, $r, $e, $payload);
@@ -625,7 +776,9 @@ final class PortalEditorialWorkflow
$updates['assigned_to'] = $responsible->id;
}
if ($action === 'archive' && $e->state === 'published') {
- if ($portfolio) {
+ if ($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()]);
@@ -697,7 +850,7 @@ final class PortalEditorialWorkflow
private function audit(User $actor, \stdClass $e, string $action, string $reason, ?string $nextState = null): void
{
$current = DB::table('portal_editorial_editions')->where('id', $e->id)->first();
- app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version,
+ app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'demographic_import_id' => $e->demographic_import_id ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version,
'from_state' => $e->state, 'to_state' => $nextState ?? $e->state, 'result_version' => $current->version, 'approval' => $current->approval, 'legal_validation' => $current->legal_validation, 'reason' => $reason]);
}
}
diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php
index a27c7d22..6e5f3ff0 100644
--- a/config/editorial_workflow.php
+++ b/config/editorial_workflow.php
@@ -5,6 +5,7 @@ return [
'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),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
diff --git a/database/migrations/2026_11_13_233000_extend_editorial_editions_to_demography.php b/database/migrations/2026_11_13_233000_extend_editorial_editions_to_demography.php
new file mode 100644
index 00000000..2656bf6e
--- /dev/null
+++ b/database/migrations/2026_11_13_233000_extend_editorial_editions_to_demography.php
@@ -0,0 +1,35 @@
+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');
+ });
+ }
+};
diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md
index 19997c47..56ae5e81 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -1,3 +1,17 @@
+## 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.
diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
index 1c3559a3..bc5db67a 100644
--- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
+++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
@@ -11131,3 +11131,4 @@ ACT-20261113-PESQUISA,Pesquisa rápida: contar corredores sem misturar referênc
ACT-20261113-EDITORIAL-F1,"Dashboard Colaborativo Fase 1: versões e autorizações; artigos, legislação, oportunidades e documentos; filas e notificações",Directiva Tempo 13/11/2026 nesta sessão; plano aprovado e autorização de implementação apenas em desenvolvimento/QA,Gestão editorial / conteúdos,/management/editorial; /management/editorial/resources/{id}; edições/anexos/notificações; editores e circuito nativo de oportunidades,Edições privadas imutáveis no circuito; versão pública preservada; aprovação/publicação separadas; decisões ligadas a conteúdo/anexos; permissões e auditoria reutilizadas; notificações com acesso revogável,Desactivado por defeito fora dos testes; estrutura instalada apenas em editorial_phase1_20261113_test:55441,outputs/editorial-phase1-20261113: integração 11/223; oportunidades 6/116 + caso reforçado 1/20; unidade 1/6; frontend 12; PHPStan/tipos/build aprovados,Implementado em QA; conferência visual e instalação operacional pendentes,Conferência visual; configuração operacional de designações e independência; instalação/activação fora do QA não executadas,Decisão concreta para instalação operacional e designações; não bloqueia as provas sintéticas QA,Editar preserva toda a publicação; JSON/prévias/anexos autorizados; conteúdo/anexos novos exigem nova decisão; aprovação distinta da publicação; filas e notificações isoladas; conflitos 409; histórico recuperável,Alta,,,,,"Fila, filtros, edição, histórico e notificações em QA","JSON nas mesmas rotas privadas, com as mesmas autorizações","Edições, decisões, notificações e auditoria; dados sintéticos revertidos pelos testes",Versões e publicação anterior recuperadas; isolamento/revogação demonstrados,Versão pública mantida durante edição e revisão; substituição só por publicação explícita,,,,"Implementação focada em lacunas, sem segundo motor de oportunidades ou alteração de permissões",Conferência visual; instalação operacional separada; Fase 2 não implementada,"DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",,Implementar em desenvolvimento/QA; não intervir em produção,Implementação aditiva e testes focados,,Recibos QA e testes em outputs/editorial-phase1-20261113; manual unificado actualizado,Dashboard Colaborativo Fase 1,13/11/2026,,,,,,,,"Reutilização dos testes nativos de oportunidade, permissão, prévias e integridade",Versão pública preservada; circuito editorial e decisões por versão/anexos; escopos de notificações e conteúdos,Sem conferência visual; sem operação em homologação/produção; tentativa inicial descartada explicitamente,,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,QA demonstrado; instalação não executada; Fase 2 pendente
ACT-20261113-EDITORIAL-CLUSTERS,"Separar aprovação/publicação da análise de Competitividade; preservar publicação, contexto, versões e autoridade; agrupar entrega relacionada","Directiva de continuidade e decisão de pacote conjunto do proprietário, 13/11/2026; Fase 2 já delimitada",Dashboard Colaborativo / Competitividade dos clusters,/management/clusters; /management/clusters/{id}/competitiveness/{action}; Gestão de conteúdos; Circuito editorial,"Extensão do circuito cifrado existente: submissão, revisão, aprovação e publicação distintas; devolução/rejeição/arquivo; versões/contexto; MFA e autoridade efectiva; navegação reutilizada",QA isolado; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados",Recorte implementado em QA; não encerra a Fase 2,Conferência visual; notificações dos clusters; versionamento dos dados-base e outros conteúdos Fase 2; preparação/instalação operacional,Instalação agrupada exige preparar e autorizar schema/contrato da Fase 1 no destino; não bloqueia QA,Aprovar não publica; rascunho/recuperação preservam publicação; alteração do contexto invalida aprovação; independência quando exigida; revogação e conflitos bloqueados; retirar publicação preserva revisão privada,Alta,,,,,Editor e lista existentes com estados distintos; entradas de gestão integradas,"JSON autenticado, MFA e autorizações verificadas no servidor",Histórico cifrado existente; sem novas tabelas ou migrações deste recorte,Histórico recuperado como nova edição; publicação anterior preservada,Publicação só por confirmação distinta após aprovação da edição exacta,,,,Provas sintéticas do delta e regressão afectada; provas anteriores reutilizadas,Conferência visual; notificações clusters e restantes conteúdos Fase 2; instalação conjunta,"ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Implementação localizada sem migração; pacote Git agrupado,,outputs/editorial-clusters-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,Regressão anterior de Competitividade e Fase 1; oportunidades mantêm provas aceites,"Ciclo editorial de Competitividade, preservação pública, permissões, independência, recuperação e contexto","Sem conferência visual ou instalação operacional; não prova versão editorial dos dados-base, notificações clusters nem restantes conteúdos Fase 2",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,QA demonstrado; entrega agrupada preparada; instalação pendente
ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas e dados-base/localização associados de projecto âncora/cluster/corredor durante revisão privada; publicação conjunta e pacote agrupado,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações; dados-base e localização propostos na mesma edição; publicação atómica; conflitos sem sobrescrever,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados; dados-base novos 6/178, estruturas/território 19/289, frontend actualizado 21; outputs/editorial-structures-20261113",Fichas e dados-base/localização associados implementados em QA; estruturas sem ficha e restantes conteúdos Fase 2 pendentes,"Conferência visual; preparação/instalação operacional; estruturas sem ficha, relações/cadeias e restantes conteúdos Fase 2",Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis; dados-base/localização e ficha aplicados atomicamente; precisão do orçamento; conflitos de código e território sem publicação parcial",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Estruturas sem ficha, relações/cadeias, estatística/demografia/território; notificações Competitividade; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado; dados-base em outputs/editorial-structures-20261113,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; recorte de dados-base apenas para entidades com ficha; não encerra estruturas sem ficha/relações nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente
+ACT-20261114-EDITORIAL-DEMO,Novos lotes demográficos no circuito editorial: aprovação separada da publicação; preservar lotes e valores anteriores; entrega agrupada,"Fase 2 delimitada e directiva de continuidade; decisão de pacote agrupado, 13–14/11/2026",Dashboard Colaborativo / indicadores demográficos,"/management/demography; /management/editorial/demography/{id}; versões, transições e original protegido","Lote nativo privado mantém imported até publicação explícita; edições e decisões comuns; originais, proveniência, dados e territórios por hash; MFA e autoridade existentes; notificações e pendências",QA isolado; flag própria desactivada por defeito; lotes anteriormente approved preservados sem novas decisões,"outputs/editorial-demography-20261114: seis casos iniciais, incluindo 131400 observações/256MB; cinco funcionais reforçados 5/66; regressão 14/52; frontend 24; PHPStan/tipos/build aprovados",Novos lotes implementados em QA; instalação operacional pendente,Conferência visual; preparação/instalação operacional; outras estatísticas e restante saldo Fase 2,Quatro migrações QA do circuito comum e contrato nominal operacional exigem preparação/autorização concreta; não bloqueia QA,Aprovar não publica; original/observações/proveniência alterados impedem publicação e reaprovação silenciosa; contexto territorial exige revisão; publicar conserva séries anteriores; arquivo conserva valores e histórico; MFA/autoridade/independência quando exigida; memória limitada,Alta,,,,,"Gestão nativa e revisão comum, originais, advertências, edições, pendências e notificações",JSON/HTML protegidos; original privado; anti-bypass das operações antigas,Edições na tabela comum e eventos demográficos nativos; observações preservadas,Histórico e fontes recuperáveis sem repetir importação; dados anteriores preservados,A série só aparece nas análises existentes depois da publicação explícita,,,,Ensaios funcionais e de memória do delta com regressão afectada; não é reauditoria dos doze exemplares aceites,"Registo territorial, outras estatísticas e restantes resíduos; conferência visual e instalação conjunta","DemographicImport, DemographicEvidence, PortalEditorialWorkflow, importações/observações/eventos existentes, perfis e MFA",,"Implementar em QA, conservar fontes aceites e agrupar a instalação; sem intervenção operacional",Adaptação no circuito comum; nenhuma nova tabela de observações ou motor paralelo,,outputs/editorial-demography-20261114; recibo de migração QA e manual unificado,Dashboard Colaborativo — pacote conjunto,14/11/2026,,,,,,,,"Regressão demográfica 14/52; Fase 1, carteira e dados-base reaproveitados/repetidos apenas no código afectado","Separação aprovação/publicação; fontes, números, advertências, jurisdição, acesso, memória e preservação histórica",Sem instalação operacional ou conferência visual; ensaio grande é sintético; não encerra outras estatísticas ou todo o registo territorial,,Publicação editorial de indicadores demográficos,"Lote privado → revisão → aprovação → publicação → análise existente → arquivo, sem alterar valores ou lotes anteriores",QA demonstrado; integrado na candidata agrupada; instalação pendente
diff --git a/resources/js/pages/management/content-hub.tsx b/resources/js/pages/management/content-hub.tsx
index 73eaa911..014cd08c 100644
--- a/resources/js/pages/management/content-hub.tsx
+++ b/resources/js/pages/management/content-hub.tsx
@@ -3,6 +3,7 @@ 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 };
@@ -29,6 +30,22 @@ export default function ContentHub({
acção respeita a sua autorização actual.
+ {access.demography && (
+
+
+ Indicadores demográficos
+
+
+ Conferir originais, observações e correspondências
+ dos novos lotes. Rever, aprovar e publicar
+ separadamente, conservando os dados anteriormente
+ disponibilizados.
+
+
+ Gerir lotes e revisões
+
+
+ )}
{access.portfolio && (
diff --git a/resources/js/pages/management/demography.tsx b/resources/js/pages/management/demography.tsx
index 1f057e70..0f484e2b 100644
--- a/resources/js/pages/management/demography.tsx
+++ b/resources/js/pages/management/demography.tsx
@@ -1,4 +1,4 @@
-import { router } from '@inertiajs/react';
+import { Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { Management, button, control } from './shared';
@@ -38,7 +38,11 @@ export default function DemographyManagement({
batches,
preview,
qa = true,
+ editorialEnabled = false,
+ editorialRevision = 0,
}: {
+ editorialEnabled?: boolean;
+ editorialRevision?: number;
qa?: boolean;
batches: {
id: string;
@@ -55,6 +59,7 @@ export default function DemographyManagement({
const [mapping, setMapping] = useState>({});
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
+ const review = useForm({ expected_revision: editorialRevision });
async function execute(path: string, body: FormData | object) {
setBusy(true);
setMessage('');
@@ -77,7 +82,11 @@ export default function DemographyManagement({
},
body: body instanceof FormData ? body : JSON.stringify(body),
});
- const data = await response.json();
+ const data = await response.json().catch(() => null);
+ if (!data)
+ throw new Error(
+ 'O servidor não confirmou a operação. Confira o estado guardado antes de repetir.',
+ );
if (!response.ok)
throw new Error(
data.message ?? 'Não foi possível executar a operação.',
@@ -100,8 +109,10 @@ export default function DemographyManagement({
>
Preparar não grava observações. Confirmar importa para consulta
- administrativa. Aprovar disponibiliza a série nas análises deste
- ambiente; desactivar conserva dados e histórico.
+ administrativa.{' '}
+ {editorialEnabled
+ ? 'A revisão, a aprovação e a publicação dos novos lotes são operações distintas. Os lotes anteriormente disponibilizados permanecem preservados.'
+ : 'Aprovar disponibiliza a série nas análises deste ambiente; desactivar conserva dados e histórico.'}
- Prepare uma edição no editor existente para iniciar a
- revisão.
+ {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.'}