From 3c4c2599f7d2443ac6c15ab7eee86025069e9064 Mon Sep 17 00:00:00 2001 From: nala Date: Sat, 14 Nov 2026 08:35:11 +0200 Subject: [PATCH] feat(editorial): review demographic batches before explicit publication --- app/Http/Controllers/DemographyController.php | 6 +- .../PortalEditorialWorkflowController.php | 51 ++++- app/Services/ContentManagement.php | 3 + app/Services/DemographicImport.php | 41 +++- app/Services/PortalEditorialWorkflow.php | 179 ++++++++++++++-- config/editorial_workflow.php | 1 + ...xtend_editorial_editions_to_demography.php | 35 ++++ .../unification/traceability-and-manual.md | 14 ++ .../v2/auditoria-portal/matriz-requisitos.csv | 1 + resources/js/pages/management/content-hub.tsx | 17 ++ resources/js/pages/management/demography.tsx | 131 +++++++++--- .../js/pages/management/editorial-edition.tsx | 70 ++++++- .../pages/management/editorial-workflow.tsx | 9 + routes/web.php | 4 + .../DemographyEditorialWorkflowTest.php | 197 ++++++++++++++++++ tests/frontend/demography-editorial.test.mjs | 95 +++++++++ tests/frontend/editorial-edition.test.mjs | 33 +++ 17 files changed, 825 insertions(+), 62 deletions(-) create mode 100644 database/migrations/2026_11_13_233000_extend_editorial_editions_to_demography.php create mode 100644 tests/Feature/DemographyEditorialWorkflowTest.php create mode 100644 tests/frontend/demography-editorial.test.mjs 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.'}

) : ( <> - {preview.status !== 'approved' && ( - - )} - {preview.status !== 'disabled' && ( - + {editorialEnabled && ( +
+ {editorialRevision > 0 && ( + + Continuar revisão e consultar + versões + + )} + {preview.status === 'approved' ? ( +

+ {editorialRevision + ? 'Lote publicado; consulte a edição e o histórico.' + : 'Lote anteriormente disponibilizado, preservado sem nova aprovação.'} +

+ ) : ( + + )} +
)} + {!editorialEnabled && + preview.status !== 'approved' && ( + + )} + {preview.status !== 'disabled' && + (!editorialEnabled || + editorialRevision === 0) && ( + + )} )}

diff --git a/resources/js/pages/management/editorial-edition.tsx b/resources/js/pages/management/editorial-edition.tsx index bad7627f..2dfdf336 100644 --- a/resources/js/pages/management/editorial-edition.tsx +++ b/resources/js/pages/management/editorial-edition.tsx @@ -17,6 +17,11 @@ type Edition = { description?: string; editorial_content?: string | null; source?: string; + publication_version?: string; + observations?: number; + sha256?: string; + dataset_hash?: string; + report?: { issue_counts?: Record }; content?: Record; _structure?: { fields: Record; @@ -51,7 +56,7 @@ export default function EditorialEdition({ independentRequired, subject = 'resource', }: { - subject?: 'resource' | 'portfolio'; + subject?: 'resource' | 'portfolio' | 'demography'; resource: { id: number | string; title: string; @@ -74,7 +79,7 @@ export default function EditorialEdition({ canApprove: boolean; independentRequired: boolean; }) { - const editionBase = `/management/editorial/${subject === 'portfolio' ? 'portfolio' : 'resources'}/${resource.id}`; + const editionBase = `/management/editorial/${subject === 'resource' ? 'resources' : subject}/${resource.id}`; const form = useForm({ version: edition?.version ?? 1, action: '', @@ -107,7 +112,14 @@ export default function EditorialEdition({ Fila e notificações - {subject === 'portfolio' ? ( + {subject === 'demography' ? ( + + Conferir lote, correspondências e advertências + + ) : subject === 'portfolio' ? ( {edition.payload.resource.description}

+ {subject === 'demography' && ( +
+

Fonte: {edition.payload.resource.source}

+

+ Exemplar/versão fornecida:{' '} + {edition.payload.resource.publication_version} +

+

+ {edition.payload.resource.observations}{' '} + observações conferidas, sem alteração dos + valores originais. +

+ + Consultar original fornecido + +

+ SHA256 do original:{' '} + {edition.payload.resource.sha256} +

+

+ Integridade das observações:{' '} + {edition.payload.resource.dataset_hash} +

+
    + {Object.entries( + edition.payload.resource.report + ?.issue_counts ?? {}, + ).map(([code, count]) => ( +
  • + {code}: {count} advertências conservadas +
  • + ))} +
+

+ Aprovar esta edição não disponibiliza a série. + Confirme publicar numa operação separada. +

+ {edition.payload.links?.map((link) => ( +

+ Território: {link.context?.name} +

+ ))} +
+ )} {subject === 'portfolio' && ( <>

Fonte: {edition.payload.resource.source}

@@ -426,8 +485,9 @@ export default function EditorialEdition({ ) : (

- 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.'}

)}

Versões conservadas

diff --git a/resources/js/pages/management/editorial-workflow.tsx b/resources/js/pages/management/editorial-workflow.tsx index ef8e5372..ed537d8e 100644 --- a/resources/js/pages/management/editorial-workflow.tsx +++ b/resources/js/pages/management/editorial-workflow.tsx @@ -40,6 +40,8 @@ export default function EditorialWorkflow({ clustersUrl, portfolioUrl, portfolioPending = 0, + demographyUrl, + demographyPending = 0, }: { resourceAccess: boolean; opportunitiesUrl: string | null; @@ -47,6 +49,8 @@ export default function EditorialWorkflow({ clustersUrl?: string | null; portfolioUrl?: string | null; portfolioPending?: number; + demographyUrl?: string | null; + demographyPending?: number; filters?: { state?: string; kind?: string }; items: { data: Item[]; current_page: number; last_page: number }; notices: { @@ -67,6 +71,11 @@ export default function EditorialWorkflow({ próprio.

+ {demographyUrl && ( + + Demografia: {demographyPending} lotes pendentes + + )} {portfolioUrl && ( Carteira: {portfolioPending} fichas pendentes diff --git a/routes/web.php b/routes/web.php index fb818412..c65fe9d3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -103,6 +103,10 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('/management/editorial/notices/{notice}/read', [PortalEditorialWorkflowController::class, 'markRead'])->whereNumber('notice'); Route::get('/management/editorial/portfolio/{id}', [PortalEditorialWorkflowController::class, 'portfolio'])->whereUuid('id'); Route::post('/management/editorial/portfolio/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'portfolioTransition'])->whereUuid(['id', 'edition']); + Route::get('/management/editorial/demography/{id}', [PortalEditorialWorkflowController::class, 'demography'])->whereUuid('id'); + Route::post('/management/editorial/demography/{id}/drafts', [PortalEditorialWorkflowController::class, 'demographyDraft'])->whereUuid('id'); + Route::post('/management/editorial/demography/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'demographyTransition'])->whereUuid(['id', 'edition']); + Route::get('/management/editorial/demography/{id}/original', [PortalEditorialWorkflowController::class, 'demographyOriginal'])->whereUuid('id'); Route::get('/gestao-conteudos', fn (Request $request) => Inertia::render('management/content-hub', app(ContentManagement::class)->view($request->user())))->name('content-management'); Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk'); Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership'); diff --git a/tests/Feature/DemographyEditorialWorkflowTest.php b/tests/Feature/DemographyEditorialWorkflowTest.php new file mode 100644 index 00000000..026f03e4 --- /dev/null +++ b/tests/Feature/DemographyEditorialWorkflowTest.php @@ -0,0 +1,197 @@ +assertTrue(IsolatedQaDatabase::allowsSchemaChanges()); + $this->withoutVite(); + Storage::fake('local'); + [$this->editor, $this->reviewer] = User::factory()->count(2)->create()->all(); + config(['portal.managers' => [$this->editor->email, $this->reviewer->email], 'portal.profile_access_enabled' => false, 'demography.enabled' => true, + 'editorial_workflow.enabled' => true, 'editorial_workflow.demography' => true, 'editorial_workflow.independent_approval.kinds' => ['demography']]); + $this->actingAsWithMfa($this->editor); + $this->actingAsWithMfa($this->reviewer); + $country = (string) Str::uuid(); + $this->province = (string) Str::uuid(); + foreach ([[$country, null, 'country'], [$this->province, $country, 'province']] as [$id, $parent, $type]) { + DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC EDITORIAL '.$type, 'code' => $id, 'location_type' => $type, + 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id, 'data_classification' => 'public']); + } + $this->legacy = $this->batch('approved', '100.125'); + $this->batch = $this->batch('imported', '110.250'); + } + + private function batch(string $status, string $value): string + { + $id = (string) Str::uuid(); + $original = 'SYNTHETIC PREIMPORTED QA ONLY '.$id; + $preview = json_encode(['source' => 'SYNTHETIC QA ONLY', 'record_count' => 1]); + Storage::disk('local')->put('demography/'.$id.'/original.xlsx', $original); + Storage::disk('local')->put('demography/'.$id.'/preview.json', $preview); + $sha = hash('sha256', $original); + DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'preview_sha256' => hash('sha256', $preview), 'file_name' => 'SYNTHETIC-EDITORIAL.xlsx', + 'source' => 'SYNTHETIC QA ONLY', 'publication_version' => 'Exemplar sintético, não é fonte INE real', 'status' => $status, 'prepared_by' => $this->editor->id, + 'report' => json_encode(['record_count' => 1, 'issue_counts' => ['unit_not_supplied' => 1], 'issues' => [], 'territories' => [], 'scan' => ['result' => 'clean', 'engine' => 'SYNTHETIC QA ONLY', 'sha256' => $sha]])]); + DB::table('demographic_observations')->insert(['import_id' => $id, 'geo_location_id' => $this->province, 'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', + 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => $value, 'numeric_value' => $value, 'sheet' => '2026', 'cell' => 'B4', 'validation_status' => 'warning']); + + return $id; + } + + private function draft(int $expected = 0, int $status = 201): ?\stdClass + { + $this->actingAsWithMfa($this->editor); + $this->postJson('/management/editorial/demography/'.$this->batch.'/drafts', ['expected_revision' => $expected])->assertStatus($status); + + return app(PortalEditorialWorkflow::class)->latestDemography($this->batch); + } + + private function action(\stdClass $edition, string $action, User $actor, int $status = 200): \stdClass + { + $this->actingAsWithMfa($actor); + $this->postJson('/management/editorial/demography/'.$this->batch.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, + 'assigned_to' => $this->reviewer->id, 'reason' => 'Conferência sintética e publicação explícita em QA'])->assertStatus($status); + + return DB::table('portal_editorial_editions')->where('id', $edition->id)->first(); + } + + private function approve(\stdClass $edition): \stdClass + { + $edition = $this->action($edition, 'submit', $this->editor); + $edition = $this->action($edition, 'start_review', $this->reviewer); + + return $this->action($edition, 'approve', $this->reviewer); + } + + public function test_approval_does_not_publish_new_series_and_legacy_values_are_preserved_through_publication_and_archive(): void + { + $legacy = DB::table('demographic_imports')->where('id', $this->legacy)->first(); + $before = DB::table('demographic_observations')->whereIn('import_id', [$this->legacy, $this->batch])->orderBy('id')->get()->toJson(); + $edition = $this->draft(); + $this->draft(0, 409); + $edition = $this->approve($edition); + $service = app(DemographicEvidence::class); + $this->assertSame('no_data', $service->analyse([$this->province], 2026, 2026, 'general', $this->batch)['state']); + $this->assertSame('approved', $edition->state); + $this->assertSame('imported', DB::table('demographic_imports')->where('id', $this->batch)->value('status')); + $this->action($edition, 'publish', $this->reviewer); + $evidence = $service->analyse([$this->province], 2026, 2026, 'general', $this->batch); + $this->assertSame('available', $evidence['state']); + $this->assertSame('110.250', $evidence['territories'][0]['series'][0]['values'][0]['original_value']); + $this->assertSame(1, $evidence['territories'][0]['series'][0]['issue_counts']['unit_not_supplied']); + $this->assertEquals($legacy, DB::table('demographic_imports')->where('id', $this->legacy)->first()); + $this->assertSame($before, DB::table('demographic_observations')->whereIn('import_id', [$this->legacy, $this->batch])->orderBy('id')->get()->toJson()); + try { + (require database_path('migrations/2026_11_13_233000_extend_editorial_editions_to_demography.php'))->down(); + $this->fail('Não eliminar histórico de publicação demográfica.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('Preserve demographic', $exception->getMessage()); + } + $published = app(PortalEditorialWorkflow::class)->latestDemography($this->batch); + $this->action($published, 'archive', $this->reviewer); + $this->assertSame('no_data', $service->analyse([$this->province], 2026, 2026, 'general', $this->batch)['state']); + $this->assertSame('approved', DB::table('demographic_imports')->where('id', $this->legacy)->value('status')); + $this->assertSame($before, DB::table('demographic_observations')->whereIn('import_id', [$this->legacy, $this->batch])->orderBy('id')->get()->toJson()); + } + + public function test_changed_observations_or_original_block_publication_and_cannot_be_blessed_by_a_new_draft(): void + { + $edition = $this->approve($this->draft()); + DB::table('demographic_observations')->where('import_id', $this->batch)->update(['numeric_value' => '999.999']); + $this->action($edition, 'publish', $this->reviewer, 409); + $this->getJson('/management/editorial/demography/'.$this->batch)->assertJsonPath('edition.fresh', false); + $this->draft(1, 409); + DB::table('demographic_observations')->where('import_id', $this->batch)->update(['numeric_value' => '110.250']); + Storage::disk('local')->put('demography/'.$this->batch.'/original.xlsx', 'SYNTHETIC CHANGED ORIGINAL'); + $this->action($edition, 'publish', $this->reviewer, 409); + $this->assertSame('imported', DB::table('demographic_imports')->where('id', $this->batch)->value('status')); + } + + public function test_changed_territorial_context_requires_a_new_decision_without_overwriting_numbers(): void + { + $edition = $this->approve($this->draft()); + DB::table('geo_locations')->where('id', $this->province)->update(['name' => 'SYNTHETIC REVISED TERRITORY']); + $this->action($edition, 'publish', $this->reviewer, 409); + $next = $this->draft(1); + $this->assertNull($next->approval); + $this->assertSame('110.250', DB::table('demographic_observations')->where('import_id', $this->batch)->value('numeric_value')); + $next = $this->approve($next); + $this->action($next, 'publish', $this->reviewer); + } + + public function test_authority_mfa_independence_original_download_and_legacy_bypass_are_enforced(): void + { + $edition = $this->draft(); + $this->postJson('/management/demography/'.$this->batch.'/transition', ['action' => 'approved', 'reason' => 'Tentativa por operação antiga'])->assertConflict(); + $this->get('/management/editorial/demography/'.$this->batch.'/original')->assertOk()->assertDownload('SYNTHETIC-EDITORIAL.xlsx'); + $edition = $this->action($edition, 'submit', $this->editor); + $edition = $this->action($edition, 'start_review', $this->reviewer); + $this->action($edition, 'approve', $this->editor, 403); + $this->actingAs(User::factory()->create())->getJson('/management/editorial/demography/'.$this->batch)->assertForbidden(); + $this->getJson('/management/editorial/demography/'.$this->batch.'/original')->assertForbidden(); + $this->actingAsWithMfa($this->reviewer); + session()->forget('portal.mfa_at'); + $this->postJson('/management/editorial/demography/'.$this->batch.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => 'approve', 'reason' => 'Tentativa sem MFA fresco'])->assertForbidden(); + $edition = $this->action($edition, 'approve', $this->reviewer); + $publisher = User::factory()->create(); + config(['portal.managers' => [$this->editor->email, $publisher->email]]); + $this->action($edition, 'publish', $publisher, 403); + config(['editorial_workflow.demography' => false]); + $this->actingAsWithMfa($this->editor)->postJson('/management/demography/'.$this->batch.'/transition', ['action' => 'approved', 'reason' => 'Tentativa após desactivação'])->assertStatus(503); + } + + public function test_legacy_public_series_need_no_new_approval_and_notices_do_not_grant_access(): void + { + $this->actingAsWithMfa($this->editor); + $this->postJson('/management/editorial/demography/'.$this->legacy.'/drafts', ['expected_revision' => 0])->assertConflict(); + $this->assertSame(0, DB::table('portal_editorial_editions')->where('demographic_import_id', $this->legacy)->count()); + $edition = $this->action($this->draft(), 'submit', $this->editor); + $this->actingAsWithMfa($this->reviewer)->getJson('/management/editorial')->assertJsonPath('demographyPending', 1)->assertJsonPath('demographyUrl', '/management/demography') + ->assertJsonPath('notices.0.href', '/management/editorial/demography/'.$this->batch.'?edition='.$edition->id); + config(['portal.managers' => [$this->editor->email]]); + $this->getJson('/management/editorial')->assertForbidden(); + } + + public function test_131400_observations_are_fingerprinted_and_published_with_bounded_php_memory(): void + { + DB::table('demographic_observations')->where('import_id', $this->batch)->delete(); + DB::statement("INSERT INTO demographic_observations(import_id,geo_location_id,year,indicator,sex,residence,age_group,unit,data_kind,original_value,numeric_value,sheet,cell,validation_status) SELECT ?,?,2026,'population','all','all','SYNTHETIC-QA-'||i,'pessoas','projected','1','1','QA','B4','valid' FROM generate_series(1,131400) i", [$this->batch, $this->province]); + $report = json_decode(DB::table('demographic_imports')->where('id', $this->batch)->value('report'), true, 512, JSON_THROW_ON_ERROR); + $report['record_count'] = 131400; + $preview = json_encode(['source' => 'SYNTHETIC QA ONLY', 'record_count' => 131400], JSON_THROW_ON_ERROR); + Storage::disk('local')->put('demography/'.$this->batch.'/preview.json', $preview); + DB::table('demographic_imports')->where('id', $this->batch)->update(['report' => json_encode($report, JSON_THROW_ON_ERROR), 'preview_sha256' => hash('sha256', $preview)]); + $edition = $this->approve($this->draft()); + $this->action($edition, 'publish', $this->reviewer); + $this->assertSame('approved', DB::table('demographic_imports')->where('id', $this->batch)->value('status')); + $this->assertSame(131400, DB::table('demographic_observations')->where('import_id', $this->batch)->count()); + $this->assertLessThan(256 * 1024 * 1024, memory_get_peak_usage(true)); + } +} diff --git a/tests/frontend/demography-editorial.test.mjs b/tests/frontend/demography-editorial.test.mjs new file mode 100644 index 00000000..77dbc1e9 --- /dev/null +++ b/tests/frontend/demography-editorial.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const source = fs.readFileSync( + new URL( + '../../resources/js/pages/management/demography.tsx', + import.meta.url, + ), + 'utf8', +); +const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const module = { exports: {} }; +new Function('require', 'module', 'exports', code)( + (name) => { + if (name === '@inertiajs/react') + return { + Link: ({ href, children }) => + React.createElement('a', { href }, children), + router: {}, + useForm: () => ({ processing: false }), + }; + if (name === './shared') + return { + Management: ({ children }) => + React.createElement('main', null, children), + button: '', + control: '', + }; + return require(name); + }, + module, + module.exports, +); +const Page = module.exports.default; +const preview = { + id: 'qa-batch', + file_name: 'synthetic.xlsx', + sha256: 'a'.repeat(64), + publication_version: 'Edição não indicada', + status: 'imported', + report: { record_count: 10, issue_counts: {}, issues: [] }, + matches: [], + territory_options: [], + events: [], +}; +const render = (changes = {}) => + renderToStaticMarkup( + React.createElement(Page, { + batches: [], + preview, + editorialEnabled: true, + editorialRevision: 0, + ...changes, + }), + ); + +test('new imported batches enter review without a combined approval and publication button', () => { + const html = render(); + assert.match(html, /Preparar revisão para publicação/); + assert.doesNotMatch(html, /Aprovar utilização em QA/); + assert.match( + html, + /aprovação e a publicação dos novos lotes são operações distintas/, + ); +}); + +test('legacy public batches remain accepted and published new batches only offer their history', () => { + const legacy = render({ preview: { ...preview, status: 'approved' } }); + assert.match(legacy, /preservado sem nova aprovação/); + assert.doesNotMatch(legacy, /Preparar revisão para publicação/); + const published = render({ + preview: { ...preview, status: 'approved' }, + editorialRevision: 1, + }); + assert.match( + published, + /href="\/management\/editorial\/demography\/qa-batch"/, + ); + assert.doesNotMatch( + published, + /Preparar nova edição de revisão|Desactivar série/, + ); +}); diff --git a/tests/frontend/editorial-edition.test.mjs b/tests/frontend/editorial-edition.test.mjs index 95349120..5e7038f1 100644 --- a/tests/frontend/editorial-edition.test.mjs +++ b/tests/frontend/editorial-edition.test.mjs @@ -236,3 +236,36 @@ test('portfolio review presents proposed base data and territory without applyin assert.match(html, /Localização proposta: Distrito sintético/); assert.doesNotMatch(html, /qa-district|territory_version/); }); + +test('demographic edition presents provenance, warnings and protected original with a separate publication step', () => { + const html = render({ + subject: 'demography', + resource: { ...props.resource, id: 'qa-batch', published: false }, + edition: { + ...props.edition, + state: 'approved', + payload: { + ...props.edition.payload, + resource: { + title: 'Exemplar sintético', + source: 'Fonte QA', + publication_version: 'Edição não indicada no original', + observations: 10, + sha256: 'a'.repeat(64), + dataset_hash: 'b'.repeat(64), + report: { issue_counts: { unit_not_supplied: 2 } }, + }, + files: [], + }, + }, + }); + assert.match(html, /10 observações conferidas/); + assert.match(html, /Edição não indicada no original/); + assert.match(html, /unit_not_supplied: 2 advertências conservadas/); + assert.match( + html, + /href="\/management\/editorial\/demography\/qa-batch\/original"/, + ); + assert.match(html, /value="publish"/); + assert.doesNotMatch(html, /Alterar artigo|Alterar ficha da carteira/); +});