From 59d108a57629fcfe0282c6c323b2fa4578feea3b Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 7 Oct 2026 09:08:43 +0200 Subject: [PATCH] resolvemos o problema dos ZEE e ZFI --- AGENTS.md | 10 + app/Console/Commands/ImportDocumentBatch.php | 276 ++++++++++++++++++ .../Commands/ImportLegalSourceArchive.php | 2 +- .../Controllers/InvestmentZoneController.php | 7 +- .../Controllers/PortalServicesController.php | 248 +++++++++++++--- .../PublicBusinessOpportunityController.php | 9 +- .../PublicBusinessOpportunityResource.php | 1 + app/Services/ExistingBusinessDrafts.php | 2 +- app/Services/InvestmentCatalogue.php | 29 +- app/Services/InvestmentZones.php | 41 +-- ...dd_document_requirement_evidence_links.php | 7 + ...e_publication_without_second_publisher.php | 23 ++ ...add_company_purchase_business_modality.php | 47 +++ .../consolidated-review-matrix-2026-10-03.md | 92 ++++++ .../document-corpus-inventory-2026-10-06.csv | 78 +++++ ...cument-corpus-reconciliation-2026-10-06.md | 97 ++++++ .../document-import-manifest.example.json | 21 ++ ...ent-requirement-associations-2026-10-06.md | 66 +++++ .../fiscal-source-references-2026-10-06.csv | 26 ++ ...gal-source-library-migration-2026-10-06.md | 2 + .../pages/investment/business-marketplace.tsx | 18 +- .../pages/investment/business-opportunity.tsx | 4 +- .../js/pages/investment/business-types.ts | 1 + resources/js/pages/investment/catalogue.tsx | 18 +- resources/js/pages/investment/zone.tsx | 15 +- resources/js/pages/portal/document.tsx | 4 +- resources/js/pages/portal/manage.tsx | 26 +- resources/js/pages/portal/services.tsx | 8 +- routes/web.php | 1 + .../Feature/BusinessMarketplacePublicTest.php | 49 +++- tests/Feature/ExistingBusinessIntakeTest.php | 39 +++ tests/Feature/ExistingBusinessWizardTest.php | 13 + tests/Feature/ImportDocumentBatchTest.php | 88 ++++++ .../Feature/ImportLegalSourceArchiveTest.php | 14 +- tests/Feature/InvestmentTerritoryTest.php | 34 +++ tests/Feature/InvestmentZoneTest.php | 68 ++++- tests/Feature/PortalServicesTest.php | 132 ++++++++- 37 files changed, 1514 insertions(+), 102 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/Console/Commands/ImportDocumentBatch.php create mode 100644 database/migrations/2026_10_06_210000_allow_informational_zone_publication_without_second_publisher.php create mode 100644 database/migrations/2026_10_06_220000_add_company_purchase_business_modality.php create mode 100644 docs/investmoz/document-corpus-inventory-2026-10-06.csv create mode 100644 docs/investmoz/document-corpus-reconciliation-2026-10-06.md create mode 100644 docs/investmoz/document-import-manifest.example.json create mode 100644 docs/investmoz/document-requirement-associations-2026-10-06.md create mode 100644 docs/investmoz/fiscal-source-references-2026-10-06.csv create mode 100644 tests/Feature/ImportDocumentBatchTest.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..16ff3deb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Regras do acervo documental InvestMoz + +- O responsável define o acervo e a finalidade. Inventariar todos os ficheiros e anexos acessíveis antes de seleccionar documentos para consulta; não omitir por não serem usados por uma funcionalidade. +- Manter três estados independentes: disponibilidade para consulta, situação jurídica/documental e utilização numa regra ou cálculo. Importação/publicação nunca activa um cálculo fiscal. +- Não usar associação a módulo, requisito ou artigo como pré-condição para cadastrar ou consultar um documento autorizado. Relações sem citação verificada ficam pendentes e visivelmente identificadas. +- Respeitar a autorização de divulgação registada e o seu âmbito. Só bloquear por impedimento concreto (por exemplo malware, dados pessoais/confidenciais ou restrição efectiva de divulgação), registado por ficheiro. Não atribuir decisões a gestores inexistentes nem contornar autenticação. +- Importações em lote devem ter pré-visualização, cálculo de hash, detecção de duplicados por bytes e conflitos de identidade/versão, escrita privada, análise antimalware e execução idempotente. Nunca publicar automaticamente um lote. +- Preservar o ficheiro original, a proveniência, o identificador exacto, a edição/data, o hash e o histórico. Não marcar autenticidade, vigência, direitos ou scan como confirmados sem evidência. +- Para fontes fiscais, inventariar também as fontes não analisadas e os diplomas/artigos sem correspondência documental. Parâmetros ausentes ou ambiguidades permanecem indeterminados; não usar zero nem a taxa mais recente como substituto. +- Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária. Testes e migrações devem usar exclusivamente uma base QA isolada com guarda contra a base activa. diff --git a/app/Console/Commands/ImportDocumentBatch.php b/app/Console/Commands/ImportDocumentBatch.php new file mode 100644 index 00000000..fbed1729 --- /dev/null +++ b/app/Console/Commands/ImportDocumentBatch.php @@ -0,0 +1,276 @@ +argument('manifest')); + $stagingReal = realpath($staging); + if (! $manifestPath || ! $stagingReal || ! str_starts_with(strtolower($manifestPath), strtolower($stagingReal.DIRECTORY_SEPARATOR)) || ! is_file($manifestPath)) { + $this->error('Manifest is missing or outside the private document-import staging directory.'); + + return self::FAILURE; + } + + foreach ([ + 'documents' => ['id', 'source_system', 'external_id', 'version_no', 'data_classification', 'file_name', 'media_type', 'storage_key', 'sha256', 'status', 'size_bytes', 'issuer', 'issued_on', 'uploaded_by', 'rights_status', 'original_source', 'malware_scan_status'], + 'portal_resources' => ['document_id', 'title', 'category', 'topic', 'published', 'source_reference', 'approved_by', 'description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label', 'source_confirmation', 'legal_status', 'rights_status', 'document_identity_key', 'metadata_version', 'created_by'], + ] as $table => $columns) { + if (! Schema::hasColumns($table, $columns)) { + $this->error('Document-library schema is incomplete; run the documented schema compatibility check before importing.'); + + return self::FAILURE; + } + } + + try { + $manifest = json_decode((string) file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + $this->error('Manifest is not valid JSON.'); + + return self::FAILURE; + } + if (! is_array($manifest) || ! isset($manifest['documents']) || ! is_array($manifest['documents']) || count($manifest['documents']) > 100) { + $this->error('Manifest must contain at most 100 documents in a documents array.'); + + return self::FAILURE; + } + + $apply = (bool) $this->option('apply'); + $actorId = $this->option('actor'); + if ($apply && (! is_numeric($actorId) || ! DB::table('users')->where('id', (int) $actorId)->exists())) { + $this->error('Apply requires --actor=; no author identity will be fabricated.'); + + return self::FAILURE; + } + $disk = Storage::disk('local'); + $seenHashes = []; + $seenExternalIds = []; + $dispatch = []; + $failures = 0; + + foreach ($manifest['documents'] as $index => $record) { + $label = is_array($record) ? (string) ($record['external_id'] ?? '#'.($index + 1)) : '#'.($index + 1); + try { + $validated = $this->validateRecord($record, $stagingReal); + $hash = $validated['sha256']; + if (isset($seenHashes[$hash])) { + $this->line($label.' | duplicate bytes within batch; references item '.$seenHashes[$hash]); + + continue; + } + $seenHashes[$hash] = $label; + if (isset($seenExternalIds[$validated['external_id']])) { + $this->warn($label.' | external_id already occurs in this batch with different bytes; identity review required'); + + continue; + } + $seenExternalIds[$validated['external_id']] = $hash; + + $existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first(); + if ($existing) { + $this->line($label.' | already imported by SHA-256; document='.$existing->id.'; no duplicate'); + + continue; + } + $externalMatch = DB::table('documents')->where('source_system', 'document-batch-import') + ->where('external_id', $validated['external_id'])->whereNull('deleted_at')->first(); + if ($externalMatch) { + $this->warn($label.' | external_id already belongs to document '.$externalMatch->id.' with different bytes; version review required'); + + continue; + } + + $identity = $this->identity($validated); + $identityMatch = DB::table('portal_resources')->where('document_identity_key', $identity)->first(); + if ($identityMatch) { + $this->warn($label.' | identity matches resource '.$identityMatch->id.' but bytes differ; version review required'); + + continue; + } + + $this->line($label.' | '.($apply ? 'will import as private draft' : 'preview: private draft')." | bytes={$validated['size_bytes']} | sha256={$hash}"); + if (! $apply) { + continue; + } + + $documentId = (string) Str::uuid(); + $storageKey = 'portal-documents/'.$documentId; + $source = fopen($validated['source_path'], 'rb'); + if (! is_resource($source)) { + throw new RuntimeException('Source could not be opened.'); + } + try { + $stored = $disk->put($storageKey, $source); + } finally { + fclose($source); + } + $storedPath = $disk->path($storageKey); + $storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false; + if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) { + $disk->delete($storageKey); + throw new RuntimeException('Private copy failed the checksum verification.'); + } + + try { + $resourceId = DB::transaction(function () use ($validated, $documentId, $storageKey, $hash, $identity, $disk, $actorId): int { + DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch:'.$hash]); + DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch-identity:'.$identity]); + if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) { + throw new RuntimeException('Matching bytes were registered concurrently; rerun preview.'); + } + if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) { + throw new RuntimeException('A matching document identity was registered concurrently; version review required.'); + } + $now = now(); + DB::table('documents')->insert([ + 'id' => $documentId, 'source_system' => 'document-batch-import', 'external_id' => $validated['external_id'], + 'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $validated['file_name'], + 'media_type' => $validated['media_type'], 'storage_key' => $storageKey, 'sha256' => $hash, + 'status' => 'quarantined', 'size_bytes' => $validated['size_bytes'], 'issuer' => $validated['issuer'], + 'issued_on' => $validated['document_date'], 'uploaded_by' => (int) $actorId, 'rights_status' => 'unconfirmed', + 'original_source' => $validated['source_url'], 'malware_scan_status' => 'pending', + 'created_at' => $now, 'updated_at' => $now, + ]); + $resourceId = DB::table('portal_resources')->insertGetId([ + 'document_id' => $documentId, 'title' => $validated['title'], 'category' => $validated['category'], + 'topic' => $validated['topic'], 'legislation' => $validated['legislation'], 'essential' => false, + 'published' => false, 'source_reference' => $validated['source_reference'], 'approved_by' => null, + 'description' => $validated['description'], 'document_type' => $validated['document_type'], + 'document_number' => $validated['document_number'], 'document_date' => $validated['document_date'], + 'source_url' => $validated['source_url'], 'language' => $validated['language'], + 'version_label' => $validated['version_label'], 'territorial_scope' => 'unknown', + 'source_confirmation' => 'unconfirmed', 'legal_status' => 'unknown', 'rights_status' => 'unconfirmed', + 'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => (int) $actorId, + 'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now, + ]); + $resource = DB::table('portal_resources')->where('id', $resourceId)->first(); + DB::table('portal_resource_revisions')->insert([ + 'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created', + 'snapshot' => json_encode(['resource' => $resource, 'associations' => []], JSON_THROW_ON_ERROR), + 'actor_user_id' => (int) $actorId, 'created_at' => $now, + ]); + DB::table('audit_events')->insert([ + 'id' => (string) Str::uuid(), 'source_system' => 'document-batch-import', + 'external_id' => $validated['external_id'], 'data_classification' => 'internal', + 'actor_user_id' => (int) $actorId, 'entity_type' => 'document', 'entity_id' => $documentId, + 'action' => 'private_document_batch_imported', + 'after_data' => json_encode(['sha256' => $hash, 'resource_id' => $resourceId, 'published' => false], JSON_THROW_ON_ERROR), + 'occurred_at' => $now, 'created_at' => $now, + ]); + + return $resourceId; + }); + $dispatch[] = $documentId; + $this->info($label.' | private draft created | resource='.$resourceId.'; pending scan; not published'); + } catch (Throwable $exception) { + $disk->delete($storageKey); + throw $exception; + } + } catch (Throwable $exception) { + $this->error($label.' | not imported: '.$exception->getMessage()); + $failures++; + } + } + + foreach ($dispatch as $documentId) { + ScanDocumentForMalware::dispatch($documentId)->afterCommit(); + } + $this->line($apply ? 'New scans queued: '.count($dispatch).'.' : 'Preview only; database and storage unchanged.'); + + return $failures > 0 ? self::FAILURE : self::SUCCESS; + } + + /** @return array */ + private function validateRecord(mixed $record, string $stagingRoot): array + { + if (! is_array($record)) { + throw new RuntimeException('Each documents entry must be an object.'); + } + foreach (['external_id', 'relative_path', 'title', 'issuer'] as $required) { + if (! isset($record[$required]) || ! is_string($record[$required]) || trim($record[$required]) === '') { + throw new RuntimeException('Required manifest field is missing: '.$required.'.'); + } + } + if (strlen($record['external_id']) > 120 || strlen($record['title']) > 250 || strlen($record['issuer']) > 180) { + throw new RuntimeException('A metadata value exceeds its permitted length.'); + } + if (isset($record['file_name']) && (! is_string($record['file_name']) || strlen(basename($record['file_name'])) > 255)) { + throw new RuntimeException('File name exceeds its permitted length.'); + } + if (isset($record['document_number']) && (! is_string($record['document_number']) || strlen($record['document_number']) > 120)) { + throw new RuntimeException('Document number exceeds its permitted length.'); + } + if (isset($record['document_date'])) { + if (! is_string($record['document_date']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $record['document_date'], $dateParts) + || ! checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1])) { + throw new RuntimeException('Document date must use a valid YYYY-MM-DD value.'); + } + } + if (isset($record['document_type']) && ! in_array($record['document_type'], ['legislation', 'procedure', 'official_form', 'digital_adaptation', 'guidance_template', 'guide', 'study', 'reference_data', 'unknown'], true)) { + throw new RuntimeException('Document type is not supported by the catalogue.'); + } + + $path = realpath($stagingRoot.DIRECTORY_SEPARATOR.$record['relative_path']); + if (! $path || ! str_starts_with(strtolower($path), strtolower($stagingRoot.DIRECTORY_SEPARATOR)) || ! is_file($path)) { + throw new RuntimeException('Source file is missing or outside the private staging directory.'); + } + $size = filesize($path); + if (! is_int($size) || $size < 1 || $size > self::MAX_BYTES) { + throw new RuntimeException('File size is outside the 1 byte to 10 MiB import limit.'); + } + $mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path); + $allowed = ['application/pdf', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'image/webp']; + if (! is_string($mime) || ! in_array($mime, $allowed, true)) { + throw new RuntimeException('Detected media type is not supported by the document library.'); + } + $sha = hash_file('sha256', $path); + if (! is_string($sha)) { + throw new RuntimeException('File hash could not be calculated.'); + } + + $sourceUrl = $record['source_url'] ?? null; + if ($sourceUrl !== null && (! is_string($sourceUrl) || strlen($sourceUrl) > 2000 || ! filter_var($sourceUrl, FILTER_VALIDATE_URL) || strtolower((string) parse_url($sourceUrl, PHP_URL_SCHEME)) !== 'https')) { + throw new RuntimeException('Source URL, when supplied, must be a valid HTTPS URL.'); + } + + return [ + ...$record, 'source_path' => $path, 'file_name' => basename((string) ($record['file_name'] ?? $path)), + 'size_bytes' => $size, 'media_type' => $mime, 'sha256' => $sha, + 'category' => (string) ($record['category'] ?? 'general'), 'topic' => (string) ($record['topic'] ?? 'unclassified'), + 'legislation' => (bool) ($record['legislation'] ?? false), 'document_type' => (string) ($record['document_type'] ?? 'unknown'), + 'document_number' => $record['document_number'] ?? null, 'document_date' => $record['document_date'] ?? null, + 'language' => (string) ($record['language'] ?? 'unknown'), 'version_label' => $record['version_label'] ?? null, + 'source_url' => $record['source_url'] ?? null, 'source_reference' => $record['source_reference'] ?? 'Origem por completar.', + 'description' => $record['description'] ?? 'Documento importado para revisão documental.', + ]; + } + + /** @param array $record */ + private function identity(array $record): string + { + return hash('sha256', mb_strtolower(implode('|', array_map('trim', [ + (string) $record['issuer'], (string) ($record['document_number'] ?? ''), (string) ($record['document_date'] ?? ''), + (string) $record['language'], (string) ($record['version_label'] ?? $record['title']), + ])))); + } +} diff --git a/app/Console/Commands/ImportLegalSourceArchive.php b/app/Console/Commands/ImportLegalSourceArchive.php index 0012336d..3674c665 100644 --- a/app/Console/Commands/ImportLegalSourceArchive.php +++ b/app/Console/Commands/ImportLegalSourceArchive.php @@ -102,7 +102,7 @@ class ImportLegalSourceArchive extends Command $links = DB::table('portal_resource_links')->where('resource_id', $resource->id)->orderBy('id')->get(); $snapshot = DB::table('portal_resources')->where('id', $resource->id)->first(); DB::table('portal_resource_revisions')->insert(['resource_id' => $resource->id, 'revision_no' => $version, - 'change_type' => 'requirement_evidence_associated', + 'change_type' => 'evidence_linked', 'snapshot' => json_encode(['resource' => $snapshot, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => null, 'created_at' => $now]); } diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php index 8ffdbe43..48b4e02f 100644 --- a/app/Http/Controllers/InvestmentZoneController.php +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -32,7 +32,7 @@ class InvestmentZoneController extends Controller $locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id') ->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')) ->whereNull('g.deleted_at')->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'g.parent_id', 'zl.scope_type', 'zl.source_reference', 'zl.validation_status']); - $source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'version_no']); + $source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no']); $operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at') ->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name'); $field = ['zee' => 'zee_id', 'zfi' => 'zfi_id', 'industrial_park' => 'park_id'][$zone->category] ?? null; @@ -125,7 +125,7 @@ class InvestmentZoneController extends Controller $stream = fopen('php://output', 'wb'); fwrite($stream, "\xEF\xBB\xBF"); fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';'); - $query->chunk(200, function ($zones) use ($stream): void { + $query->chunk(200, function ($zones) use ($stream, $geography): void { foreach ($zones as $zone) { $territoryPath = $geography->path($zone->primary_territory_id); $province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province; @@ -221,9 +221,6 @@ class InvestmentZoneController extends Controller public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse { - if ($action === 'publish') { - InvestmentZoneAccess::requireRole($request->user(), 'publishers'); - } $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); return response()->json($action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id) diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php index 697a7fca..44d737a3 100644 --- a/app/Http/Controllers/PortalServicesController.php +++ b/app/Http/Controllers/PortalServicesController.php @@ -13,6 +13,7 @@ use Illuminate\Database\QueryException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -108,7 +109,11 @@ class PortalServicesController extends Controller ])->paginate(20)->withQueryString(); $pageResult->getCollection()->transform(function ($resource) { $resource->links = DB::table('portal_resource_links')->where('resource_id', $resource->id) - ->orderBy('target_type')->orderBy('target_key')->get(['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference', 'review_status']); + ->orderBy('target_type')->orderBy('target_key')->get($this->resourceLinkColumns()); + $resource->links->transform(fn ($link) => (object) [...(array) $link, + 'target_label' => $link->target_type === 'requirement' + ? config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key) + : null]); $resource->download_url = route('services.download', ['id' => $resource->id]); $resource->view_url = in_array($resource->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true) ? route('services.document.view', ['id' => $resource->id]) : null; @@ -186,8 +191,10 @@ class PortalServicesController extends Controller $resources->transform(function ($resource) use ($matches): array { $matched = DB::table('portal_resource_links')->where('resource_id', $resource->id)->whereIn('target_type', array_column($matches, 'type')) ->whereIn('target_key', array_column($matches, 'key'))->orderBy('target_type')->first(['target_type', 'target_key', 'applicability', 'condition_text', 'basis_reference']); - $evidence = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement') - ->where('review_status', 'confirmed')->orderBy('target_key')->get(['target_key', 'basis_reference', 'review_status']); + $evidence = $this->requirementEvidenceEnabled() + ? DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement') + ->where('review_status', 'confirmed')->orderBy('target_key')->get(['target_key', 'basis_reference', 'review_status']) + : collect(); return [...(array) $resource, 'download_url' => route('services.download', ['id' => $resource->id]), 'recommendation_reason' => $matched?->condition_text ?? ('Relacionado com '.$matched?->target_type.' '.$matched?->target_key.'; confirme a aplicabilidade ao seu projecto.'), @@ -268,7 +275,11 @@ class PortalServicesController extends Controller abort_unless($resource !== null, 404); $resource->links = DB::table('portal_resource_links')->where('resource_id', $id) ->orderBy('target_type')->orderBy('target_key') - ->get(['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference', 'review_status']); + ->get($this->resourceLinkColumns()); + $resource->links->transform(fn ($link) => (object) [...(array) $link, + 'target_label' => $link->target_type === 'requirement' + ? config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key) + : null]); $territory = $resource->territory_id ? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null; @@ -326,6 +337,10 @@ class PortalServicesController extends Controller public function manage(MalwareScanner $scanner): Response { + $disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction') + ->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1') + ->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']); + $disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null; $resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200) ->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']); $links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id'); @@ -337,11 +352,20 @@ class PortalServicesController extends Controller $malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM); return Inertia::render('portal/manage', ['resources' => $resources, - 'requirementCatalogue' => collect(config('document-requirements.catalogue'))->map(fn ($item, $key) => ['key' => $key, ...$item])->values(), + 'requirementCatalogue' => $this->requirementEvidenceEnabled() + ? collect(config('document-requirements.catalogue'))->map(fn ($item, $key) => ['key' => $key, ...$item])->values() + : collect(), + 'requirementAssociationsEnabled' => $this->requirementEvidenceEnabled(), 'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(), 'documents' => DB::table('documents')->where('status', '!=', 'deleted')->whereNull('deleted_at')->latest()->limit(250) ->get(['id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts']), 'malwareHealth' => $malwareHealth, + 'disclosureAuthorization' => $disclosureAuthorizationData ? [ + 'external_id' => $disclosureAuthorization->external_id, + 'occurred_at' => $disclosureAuthorization->occurred_at, + 'resources' => DB::table('portal_resources')->whereIn('id', $disclosureAuthorizationData['resource_ids'] ?? []) + ->orderBy('id')->get(['id', 'title', 'published', 'rights_status']), + ] : null, 'malwareCounts' => DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted') ->select('malware_scan_status', DB::raw('count(*) as total'))->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'), 'territories' => DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at') @@ -355,12 +379,12 @@ class PortalServicesController extends Controller public function upload(Request $request): RedirectResponse { - $request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'], + $this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'], 'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]); $file = $request->file('file'); $checksum = hash_file('sha256', $file->getRealPath()); if (DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists()) { - return back()->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']); + return to_route('services.manage')->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']); } $id = (string) Str::uuid(); $key = $file->storeAs('portal-documents', $id, 'local'); @@ -385,7 +409,7 @@ class PortalServicesController extends Controller ScanDocumentForMalware::dispatch($id)->afterCommit(); - return back()->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.'); + return to_route('services.manage')->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.'); } public function resource(Request $request): RedirectResponse @@ -412,7 +436,7 @@ class PortalServicesController extends Controller return $id; }); - return back()->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.'); + return to_route('services.manage')->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.'); } public function updateResource(Request $request, int $id): RedirectResponse @@ -448,12 +472,12 @@ class PortalServicesController extends Controller $this->audit($request, 'resource_metadata_updated', 'portal_resources', $id); }); - return back()->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.'); + return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.'); } public function moderate(Request $request, string $type, int $id): RedirectResponse { - $data = $request->validate(['published' => ['required', 'boolean']]); + $data = $this->validateManagement($request, ['published' => ['required', 'boolean']]); $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships'; DB::transaction(function () use ($request, $data, $table, $type, $id) { abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404); @@ -462,18 +486,22 @@ class PortalServicesController extends Controller ->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.source_url', 'd.status', 'd.data_classification', 'd.deleted_at', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.sha256']); - abort_unless($resource && $resource->status === 'active' && $resource->data_classification === 'public' && $resource->deleted_at === null, - 422, 'O ficheiro continua privado/em quarentena e não pode ser publicado.'); - abort_unless($resource->rights_status === 'authorized' && $resource->file_rights_status === 'authorized', - 422, 'Confirme e registe os direitos de utilização antes de publicar.'); - abort_unless($resource->source_confirmation !== 'unconfirmed' && $resource->source_url, - 422, 'Identifique e confirme a fonte documental antes de publicar.'); - abort_unless($resource->malware_scan_status === 'clean' - && hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256)), 422, - 'A análise antimalware tem de terminar antes da publicação.'); - if ($resource->document_type === 'legislation' || (bool) $resource->legislation) { - abort_unless($resource->source_confirmation === 'official_confirmed', - 422, 'Diplomas exigem confirmação da fonte oficial. Se a vigência não estiver esclarecida, publique-a como por confirmar.'); + abort_unless($resource !== null && $resource->deleted_at === null, 404); + if ($resource->status !== 'active') { + throw $this->managementValidationException(['published' => 'A análise técnica ainda não libertou o ficheiro da quarentena. Aguarde a análise antimalware antes de publicar.']); + } + if ($resource->data_classification !== 'public') { + throw $this->managementValidationException(['published' => 'O ficheiro já saiu da quarentena técnica, mas continua classificado como interno. Reveja os direitos e a possibilidade de divulgação; só então altere explicitamente a classificação para pública.']); + } + if ($resource->rights_status !== 'authorized' || $resource->file_rights_status !== 'authorized') { + throw $this->managementValidationException(['published' => 'Confirme e registe os direitos de utilização antes de publicar.']); + } + if ($resource->source_confirmation === 'unconfirmed' || ! $resource->source_url) { + throw $this->managementValidationException(['published' => 'Identifique e confirme a fonte documental antes de publicar.']); + } + if ($resource->malware_scan_status !== 'clean' + || ! hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256))) { + throw $this->managementValidationException(['published' => 'A análise antimalware tem de terminar antes da publicação.']); } } DB::table($table)->where('id', $id)->update($type === 'resources' @@ -487,18 +515,128 @@ class PortalServicesController extends Controller $this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id); }); - return back()->with('success', 'Publicação actualizada.'); + return to_route('services.manage')->with('success', 'Publicação actualizada.'); } - private function audit(Request $request, string $action, string $table, int $id): void + public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse + { + $event = DB::table('audit_events')->where('source_system', 'user-instruction') + ->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1') + ->where('action', 'public_disclosure_authorized')->first(); + abort_unless($event !== null, 404); + $authorization = json_decode($event->after_data, true); + $resourceIds = array_values(array_unique(array_map('intval', $authorization['resource_ids'] ?? []))); + abort_unless(count($resourceIds) === 4, 409, 'O âmbito da autorização registada foi alterado; interrompida a publicação.'); + + $published = []; + $skipped = []; + DB::transaction(function () use ($request, $event, $resourceIds, &$published, &$skipped): void { + foreach ($resourceIds as $resourceId) { + $resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') + ->where('r.id', $resourceId)->lockForUpdate()->first([ + 'r.id', 'r.title', 'r.document_type', 'r.legislation', 'r.source_url', 'r.source_confirmation', + 'r.rights_status as resource_rights', 'r.published', 'r.approved_by', 'r.metadata_version', + 'd.id as document_id', 'd.storage_key', 'd.sha256', 'd.status', 'd.deleted_at', 'd.rights_status as file_rights', + 'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.data_classification', + ]); + if ($resource === null) { + $skipped[$resourceId] = 'Registo ou ficheiro inexistente.'; + continue; + } + if ($resource->published && $resource->data_classification === 'public' && $resource->resource_rights === 'authorized' && $resource->file_rights === 'authorized') { + $published[$resourceId] = 'Já publicado.'; + continue; + } + $path = Storage::disk('local')->path($resource->storage_key); + $root = realpath(Storage::disk('local')->path('')); + $realPath = realpath($path); + $actualHash = $realPath && is_file($realPath) && $root + && str_starts_with(strtolower($realPath), strtolower($root.DIRECTORY_SEPARATOR)) + ? hash_file('sha256', $realPath) : false; + if ($resource->deleted_at !== null || $resource->status !== 'active' + || $resource->malware_scan_status !== 'clean' || ! is_string($actualHash) + || ! hash_equals((string) $resource->sha256, $actualHash) + || ! hash_equals((string) $resource->sha256, (string) $resource->malware_scanned_sha256)) { + $skipped[$resourceId] = 'Ficheiro indisponível, não limpo ou diferente dos bytes analisados.'; + continue; + } + if (in_array($resource->resource_rights, ['restricted'], true) || in_array($resource->file_rights, ['restricted'], true) + || $resource->source_confirmation === 'unconfirmed' || ! filter_var($resource->source_url, FILTER_VALIDATE_URL) + || parse_url($resource->source_url, PHP_URL_SCHEME) !== 'https') { + $skipped[$resourceId] = 'Restrição de divulgação ou origem identificada em falta.'; + continue; + } + + $now = now(); + $nextVersion = (int) $resource->metadata_version + 1; + DB::table('documents')->where('id', $resource->document_id)->update([ + 'rights_status' => 'authorized', 'data_classification' => 'public', 'updated_at' => $now, + ]); + DB::table('portal_resources')->where('id', $resourceId)->update([ + 'rights_status' => 'authorized', 'published' => true, 'approved_by' => $request->user()->id, + 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => $now, + 'metadata_version' => $nextVersion, 'updated_at' => $now, + ]); + $this->attachAuthorizedDocumentaryEvidence($resourceId, $resource->title); + $this->recordResourceRevision($resourceId, 'published', $request->user()->id, $nextVersion); + $this->audit($request, 'published_under_user_authorization', 'portal_resources', $resourceId, [ + 'authorization_source_system' => $event->source_system, + 'authorization_external_id' => $event->external_id, + 'source_confirmation' => $resource->source_confirmation, + 'legal_status_preserved' => true, + ]); + $published[$resourceId] = 'Publicado com a situação de fonte e vigência já registada.'; + } + }); + + return to_route('services.manage')->with('success', count($published).' documento(s) disponibilizado(s); '.count($skipped).' pendência(s) individual(is).') + ->with('disclosureResults', ['published' => $published, 'skipped' => $skipped]); + } + + private function audit(Request $request, string $action, string $table, int $id, array $additional = []): void { DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table, - 'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]); + 'actor_user_id' => $request->user()->id, 'action' => $action, + 'after_data' => json_encode(['record_id' => $id, ...$additional], JSON_THROW_ON_ERROR)]); + } + + private function attachAuthorizedDocumentaryEvidence(int $resourceId, string $title): void + { + if (! $this->requirementEvidenceEnabled()) { + return; + } + + $evidence = match (true) { + str_contains($title, 'Lei n.º 22/2022') => [['fiscal.iva.lei_22_2022', 'confirmed', 'Lei n.º 22/2022, art. 17(1); citação conferida no exemplar cujo SHA-256 coincide com o ficheiro oficial. Isto é evidência documental, não valida vigência ou cálculo.']], + str_contains($title, 'BR 248/2025') || str_contains($title, 'n.º 248/2025') => array_map(fn (array $law) => ['fiscal.'.$law[0].'.'.$law[1], 'pending', 'BR n.º 248/2025, Suplemento n.º 1, '.$law[2].'; artigo/dispositivo específico não conferido nesta associação. Evidência identificada, não fundamento confirmado.'], [ + ['ice', 'lei_7_2025', 'Lei n.º 7/2025'], ['customs', 'lei_8_2025', 'Lei n.º 8/2025'], + ['ispc', 'lei_9_2025', 'Lei n.º 9/2025'], ['iva', 'lei_10_2025', 'Lei n.º 10/2025'], + ['irps', 'lei_11_2025', 'Lei n.º 11/2025'], ['irpc', 'lei_12_2025', 'Lei n.º 12/2025'], + ]), + str_contains($title, 'Lei n.º 13/2016') || str_contains($title, 'BR n.º 89') => [['fiscal.iva.lei_13_2016_rectification_2017', 'pending', 'BR n.º 89/2017, I Série, p. 569, aviso relativo à Lei n.º 13/2016; a correspondência do exemplar local e os dispositivos rectificados aguardam conferência. Evidência identificada, não fundamento confirmado.']], + default => [], + }; + + foreach ($evidence as [$key, $reviewStatus, $basis]) { + if (! array_key_exists($key, config('document-requirements.catalogue', []))) { + continue; + } + $exists = DB::table('portal_resource_links')->where('resource_id', $resourceId) + ->where('target_type', 'requirement')->where('target_key', $key)->exists(); + if ($exists) { + continue; + } + DB::table('portal_resource_links')->insert([ + 'resource_id' => $resourceId, 'target_type' => 'requirement', 'target_key' => $key, + 'relationship' => 'cites', 'applicability' => 'evidence', 'basis_reference' => $basis, + 'review_status' => $reviewStatus, 'created_at' => now(), 'updated_at' => now(), + ]); + } } private function validateResource(Request $request): array { - $data = $request->validate([ + $data = $this->validateManagement($request, [ 'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'], 'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'], 'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'], @@ -515,7 +653,7 @@ class PortalServicesController extends Controller 'data_classification' => ['nullable', 'in:internal,public'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], - 'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,requirement'], + 'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source'.($this->requirementEvidenceEnabled() ? ',requirement' : '')], 'associations.*.target_key' => ['required', 'string', 'max:180'], 'associations.*.review_status' => ['nullable', 'in:pending,confirmed'], 'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'], @@ -523,24 +661,47 @@ class PortalServicesController extends Controller 'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'], ]); if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) { - throw ValidationException::withMessages(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']); + throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']); } foreach ($data['associations'] ?? [] as $index => $association) { + if (($association['target_type'] ?? null) === 'requirement' && ! $this->requirementEvidenceEnabled()) { + throw $this->managementValidationException(["associations.$index.target_type" => 'As associações documentais a requisitos ainda aguardam a migração do catálogo.']); + } if (($association['target_type'] ?? null) === 'requirement' && ! array_key_exists($association['target_key'], config('document-requirements.catalogue', []))) { - throw ValidationException::withMessages(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']); + throw $this->managementValidationException(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']); } if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) { - throw ValidationException::withMessages(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']); + throw $this->managementValidationException(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']); } if ($association['applicability'] === 'conditional' && mb_strlen(trim($association['condition_text'] ?? '')) < 5) { - throw ValidationException::withMessages(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']); + throw $this->managementValidationException(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']); } } return $data; } + /** Keep validation redirects on the management GET page even if the request + * arrived with a stale Referer pointing at a write-only resource endpoint. + */ + private function validateManagement(Request $request, array $rules): array + { + try { + return $request->validate($rules); + } catch (ValidationException $exception) { + throw $this->managementValidationException($exception->errors()); + } + } + + private function managementValidationException(array $errors): ValidationException + { + $exception = ValidationException::withMessages($errors); + $exception->redirectTo = route('services.manage'); + + return $exception; + } + private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array { unset($data['associations'], $data['issuer'], $data['data_classification']); @@ -567,7 +728,7 @@ class PortalServicesController extends Controller 'resource_id' => $resourceId, 'target_type' => $link['target_type'], 'target_key' => $link['target_key'], 'relationship' => $link['relationship'], 'applicability' => $link['applicability'], 'condition_text' => $link['condition_text'] ?? null, 'basis_reference' => $link['basis_reference'] ?? null, - 'review_status' => $link['review_status'] ?? 'pending', + ...($this->requirementEvidenceEnabled() ? ['review_status' => $link['review_status'] ?? 'pending'] : []), 'created_at' => now(), 'updated_at' => now(), ]); } @@ -586,10 +747,6 @@ class PortalServicesController extends Controller 'Registe a autorização de utilização antes de classificar o ficheiro como público.'); abort_unless($data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']), 422, 'Identifique e confirme a fonte antes de classificar o ficheiro como público.'); - if (($data['legislation'] ?? false) || ($data['document_type'] ?? null) === 'legislation') { - abort_unless($data['source_confirmation'] === 'official_confirmed', 422, - 'Diplomas exigem confirmação da fonte oficial antes de serem classificados como públicos.'); - } } private function identityKey(array $data): ?string @@ -611,6 +768,23 @@ class PortalServicesController extends Controller 'actor_user_id' => $actor, 'created_at' => now()]); } + private function requirementEvidenceEnabled(): bool + { + return Schema::hasColumn('portal_resource_links', 'review_status') + && Schema::hasTable('migrations') + && DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists(); + } + + private function resourceLinkColumns(): array + { + $columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference']; + if (Schema::hasColumn('portal_resource_links', 'review_status')) { + $columns[] = 'review_status'; + } + + return $columns; + } + private function territoryScopeIds(string $id): array { return collect(DB::select(<<<'SQL' diff --git a/app/Http/Controllers/PublicBusinessOpportunityController.php b/app/Http/Controllers/PublicBusinessOpportunityController.php index abd857bd..5688f970 100644 --- a/app/Http/Controllers/PublicBusinessOpportunityController.php +++ b/app/Http/Controllers/PublicBusinessOpportunityController.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -19,6 +20,7 @@ class PublicBusinessOpportunityController extends Controller { $filters = $request->validate([ 'q' => ['nullable', 'string', 'max:120'], + 'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)], 'sector' => ['nullable', 'integer', 'min:1'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], @@ -46,6 +48,9 @@ class PublicBusinessOpportunityController extends Controller if (! empty($filters['sector'])) { $query->where('existing_businesses.sector_id', $filters['sector']); } + if (! empty($filters['modality'])) { + $query->where('existing_businesses.modality', $filters['modality']); + } $results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->withQueryString()); if ($request->expectsJson()) { @@ -65,7 +70,7 @@ class PublicBusinessOpportunityController extends Controller $returnFilters = $request->query(); - return Inertia::render('investment/business-opportunity', ['record' => $resource, + return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request), 'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters), 'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]); } @@ -98,7 +103,7 @@ class PublicBusinessOpportunityController extends Controller return ExistingBusiness::query()->published()->with('sector:id,name,slug') ->leftJoinSub($publicLocations, 'public_location', 'public_location.existing_business_id', '=', 'existing_businesses.id') - ->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title', + ->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title', 'existing_businesses.modality', 'existing_businesses.activity_code', 'existing_businesses.stage', 'existing_businesses.objective', 'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max', 'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id', diff --git a/app/Http/Resources/PublicBusinessOpportunityResource.php b/app/Http/Resources/PublicBusinessOpportunityResource.php index 9a5acb1a..e334ddbe 100644 --- a/app/Http/Resources/PublicBusinessOpportunityResource.php +++ b/app/Http/Resources/PublicBusinessOpportunityResource.php @@ -14,6 +14,7 @@ class PublicBusinessOpportunityResource extends JsonResource 'id' => data_get($this->resource, 'id'), 'slug' => data_get($this->resource, 'public_slug'), 'title' => data_get($this->resource, 'public_title'), + 'modality' => data_get($this->resource, 'modality'), 'activity_code' => data_get($this->resource, 'activity_code'), 'stage' => data_get($this->resource, 'stage'), 'objective' => data_get($this->resource, 'objective'), diff --git a/app/Services/ExistingBusinessDrafts.php b/app/Services/ExistingBusinessDrafts.php index 0f5b05aa..f80d2e67 100644 --- a/app/Services/ExistingBusinessDrafts.php +++ b/app/Services/ExistingBusinessDrafts.php @@ -15,7 +15,7 @@ use Illuminate\Validation\Rule; final class ExistingBusinessDrafts { - public const MODALITIES = ['company_sale', 'participation_sale', 'capital_increase', 'financial_investor', 'strategic_partner', 'buyer_search', 'position_assignment', 'franchise', 'asset_sale', 'asset_lease', 'business_unit_transfer', 'joint_venture', 'succession', 'recovery', 'expansion', 'internationalization', 'real_estate', 'agricultural_investment', 'land_right', 'mining_license', 'forestry_license', 'investment_project', 'business_partnership', 'financing_request', 'concession_opportunity', 'supply_opportunity']; + public const MODALITIES = ['company_sale', 'company_purchase', 'participation_sale', 'capital_increase', 'financial_investor', 'strategic_partner', 'buyer_search', 'position_assignment', 'franchise', 'asset_sale', 'asset_lease', 'business_unit_transfer', 'joint_venture', 'succession', 'recovery', 'expansion', 'internationalization', 'real_estate', 'agricultural_investment', 'land_right', 'mining_license', 'forestry_license', 'investment_project', 'business_partnership', 'financing_request', 'concession_opportunity', 'supply_opportunity']; /** @param array $input */ public function save(User $actor, array $input, ?string $id = null): ExistingBusiness diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 02ab0695..243b9224 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -68,7 +68,8 @@ class InvestmentCatalogue foreach (['cluster' => 'clusters', 'corridor' => 'corridors'] as $dimension => $relation) { if (($filters['dimension'] ?? null) === $dimension) { $foreign = $dimension.'_id'; - $query->whereIn('opportunities.id', DB::table('opportunity_'.$relation)->whereIn($foreign, InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))->select('opportunity_id')); + $query->whereIn('opportunities.id', DB::table('opportunity_'.$relation.' as linked') + ->whereIn('linked.'.$foreign, $this->publicStructureIds($relation, $foreign))->select('linked.opportunity_id')); } } foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $field => $category) { @@ -106,10 +107,16 @@ class InvestmentCatalogue continue; } - $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) - ->whereColumn('link.opportunity_id', 'opportunities.id')->when($relation === 'locations', fn ($q) => $q->whereIn('link.'.$foreign, $locationIds ?? []), fn ($q) => $q->where('link.'.$foreign, $filters[$foreign]))->whereNull('target.deleted_at') - ->when($relation === 'locations', fn ($q) => $q->where('target.data_classification', 'public'), - fn ($q) => $q->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)))); + if (in_array($relation, ['clusters', 'corridors'], true)) { + $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link') + ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign]) + ->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign))); + } else { + $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) + ->whereColumn('link.opportunity_id', 'opportunities.id')->when($relation === 'locations', fn ($q) => $q->whereIn('link.'.$foreign, $locationIds ?? []), fn ($q) => $q->where('link.'.$foreign, $filters[$foreign]))->whereNull('target.deleted_at') + ->when($relation === 'locations', fn ($q) => $q->where('target.data_classification', 'public'), + fn ($q) => $q->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)))); + } } } if (! empty($filters['education'])) { @@ -152,6 +159,8 @@ class InvestmentCatalogue if (! $management) { if ($relation === 'locations') { $query->where('data_classification', 'public'); + } elseif (in_array($relation, ['clusters', 'corridors'], true)) { + $query->whereIn('id', $this->publicStructureIds($relation, $foreign)); } else { $query->whereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)); } @@ -168,6 +177,16 @@ class InvestmentCatalogue return $result; } + private function publicStructureIds(string $relation, string $foreign): \Illuminate\Database\Query\Builder + { + $table = self::RELATIONS[$relation][1]; + + return DB::table($table)->whereNull('deleted_at')->where(function ($query) use ($foreign): void { + $query->where('data_classification', 'public') + ->orWhereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign)); + })->select('id'); + } + /** @return array */ public function context(int $id, bool $management = false): array { diff --git a/app/Services/InvestmentZones.php b/app/Services/InvestmentZones.php index 111e9d60..00c32eff 100644 --- a/app/Services/InvestmentZones.php +++ b/app/Services/InvestmentZones.php @@ -35,16 +35,20 @@ class InvestmentZones } return InvestmentZone::query()->where('publication_status', 'published')->where('validation_status', 'validated') - ->where(function ($query): void { - $query->where(function ($legal): void { - $legal->whereIn('category', ['zee', 'zfi'])->whereExists(fn ($q) => $q->selectRaw('1')->from('investment_legal_sources as source') - ->whereColumn('source.id', 'investment_zones.legal_source_id')->where('source.validation_status', 'validated') - ->whereDate('source.valid_from', '<=', today()) - ->where(fn ($q) => $q->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', today()))); - })->orWhere(function ($informational): void { - $informational->whereNotIn('category', ['zee', 'zfi'])->whereRaw('jsonb_array_length(source_evidence) > 0') - ->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(source_evidence) AS evidence WHERE evidence->>'url' LIKE 'https://%')"); - }); + ->where(function (Builder $query): void { + $query->where(function (Builder $evidence): void { + $evidence->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(source_evidence) AS evidence WHERE evidence->>'source_type' IN ('government','institutional') AND evidence->>'url' LIKE 'https://%' AND length(trim(COALESCE(evidence->>'supports',''))) > 0)") + ->orWhere(function (Builder $promoterEvidence): void { + // Industrial parks may be listed as informational projects on the promoter's own site. + // Their promoter source must remain visibly identified and does not validate legal status, + // authorized activities, operation, incentives, or land availability. + $promoterEvidence->where('category', 'industrial_park') + ->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(source_evidence) AS evidence WHERE evidence->>'source_type' = 'promoter' AND evidence->>'url' LIKE 'https://%' AND length(trim(COALESCE(evidence->>'supports',''))) > 0)"); + }); + })->orWhereExists(fn ($source) => $source->selectRaw('1')->from('investment_legal_sources as source') + ->whereColumn('source.id', 'investment_zones.legal_source_id')->where('source.validation_status', 'validated') + ->whereDate('source.valid_from', '<=', today()) + ->where(fn ($valid) => $valid->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', today()))); }); } @@ -113,15 +117,18 @@ class InvestmentZones return DB::transaction(function () use ($id, $version, $actor) { $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); - abort_if((int) $zone->editor_id === $actor, 403, 'A revisão exige outro gestor autorizado.'); $source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->lockForUpdate()->first(); + $evidence = is_array($zone->source_evidence) ? $zone->source_evidence : []; + $officialEvidence = collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional'], true) + && str_starts_with((string) ($item['url'] ?? ''), 'https://') && trim((string) ($item['supports'] ?? '')) !== ''); + $promoterEvidence = $zone->category === 'industrial_park' && collect($evidence)->contains(fn ($item) => ($item['source_type'] ?? null) === 'promoter' + && str_starts_with((string) ($item['url'] ?? ''), 'https://') && trim((string) ($item['supports'] ?? '')) !== ''); if (in_array($zone->category, ['zee', 'zfi'], true)) { - abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString() - && (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica válida e revista em falta para este regime.'); + $legalSourceCurrent = $source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString() + && (! $source->valid_until || $source->valid_until >= today()->toDateString()); + abort_unless($officialEvidence || $legalSourceCurrent, 422, 'Registe uma fonte oficial ou institucional HTTPS que sustente a identidade e o enquadramento informativo.'); } else { - $evidence = is_array($zone->source_evidence) ? $zone->source_evidence : []; - abort_unless(collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional', 'promoter'], true) - && str_starts_with((string) ($item['url'] ?? ''), 'https://')), 422, 'Fonte institucional ou do promotor com URL HTTPS em falta.'); + abort_unless($officialEvidence || $promoterEvidence, 422, 'Registe uma fonte HTTPS identificada que sustente a identidade e a localização informativa.'); } abort_unless($zone->category !== 'pending' && trim((string) $zone->name) !== '' && trim((string) $zone->description) !== '', 422, 'Identidade, categoria e descrição são obrigatórias.'); @@ -134,7 +141,7 @@ class InvestmentZones $publicLocation = $publicLocation || DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id') ->where('zl.zone_id', $id)->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')->whereNull('g.deleted_at') ->whereIn('g.id', app(InvestmentGeography::class)->publicReferences()->select('id'))->exists(); - abort_unless($publicLocation, 422, 'Public, independently reviewed territory required.'); + abort_unless($publicLocation, 422, 'Seleccione uma província ou distrito existente e público.'); $zone->fill(['version_no' => $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published', 'validation_status' => 'validated', 'published_at' => now()])->save(); $this->snapshot($zone, $actor, 'published'); diff --git a/database/migrations/2026_10_06_200000_add_document_requirement_evidence_links.php b/database/migrations/2026_10_06_200000_add_document_requirement_evidence_links.php index 3a549cbf..cbd45aee 100644 --- a/database/migrations/2026_10_06_200000_add_document_requirement_evidence_links.php +++ b/database/migrations/2026_10_06_200000_add_document_requirement_evidence_links.php @@ -15,6 +15,8 @@ return new class extends Migration DB::statement("ALTER TABLE portal_resource_links DROP CONSTRAINT portal_resource_links_target_type_check"); DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_target_type_check CHECK (target_type IN ('theme','module','activity','stage','procedure','entity','territory','document','investment_type','legal_source','requirement'))"); DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_review_status_check CHECK (review_status IN ('pending','confirmed'))"); + DB::statement('ALTER TABLE portal_resource_revisions DROP CONSTRAINT portal_resource_revisions_change_type_check'); + DB::statement("ALTER TABLE portal_resource_revisions ADD CONSTRAINT portal_resource_revisions_change_type_check CHECK (change_type IN ('created','metadata_updated','published','unpublished','evidence_linked'))"); } public function down(): void @@ -22,6 +24,11 @@ return new class extends Migration if (DB::table('portal_resource_links')->where('target_type', 'requirement')->exists()) { throw new RuntimeException('Requirement evidence links exist; reconcile them before rollback.'); } + if (DB::table('portal_resource_revisions')->where('change_type', 'evidence_linked')->exists()) { + throw new RuntimeException('Evidence association revisions exist; reconcile them before rollback.'); + } + DB::statement('ALTER TABLE portal_resource_revisions DROP CONSTRAINT portal_resource_revisions_change_type_check'); + DB::statement("ALTER TABLE portal_resource_revisions ADD CONSTRAINT portal_resource_revisions_change_type_check CHECK (change_type IN ('created','metadata_updated','published','unpublished'))"); DB::statement('ALTER TABLE portal_resource_links DROP CONSTRAINT portal_resource_links_target_type_check, DROP CONSTRAINT portal_resource_links_review_status_check'); DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_target_type_check CHECK (target_type IN ('theme','module','activity','stage','procedure','entity','territory','document','investment_type','legal_source'))"); Schema::table('portal_resource_links', fn (Blueprint $table) => $table->dropColumn('review_status')); diff --git a/database/migrations/2026_10_06_210000_allow_informational_zone_publication_without_second_publisher.php b/database/migrations/2026_10_06_210000_allow_informational_zone_publication_without_second_publisher.php new file mode 100644 index 00000000..71e4e7ac --- /dev/null +++ b/database/migrations/2026_10_06_210000_allow_informational_zone_publication_without_second_publisher.php @@ -0,0 +1,23 @@ + 'published' OR (validation_status='validated' AND category <> 'pending' AND reviewer_id IS NOT NULL AND published_at IS NOT NULL AND length(trim(name)) > 0 AND physical_category IS NOT NULL AND physical_category <> 'unknown' AND (legal_source_id IS NOT NULL OR jsonb_array_length(source_evidence)>0)))"); + } + + public function down(): void + { + if (DB::table('investment_zones')->where('publication_status', 'published')->whereColumn('reviewer_id', 'editor_id')->exists()) { + throw new RuntimeException('A published zone uses the same author and publisher. Reconcile it before restoring the two-person requirement.'); + } + + DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_publication_requirements_check'); + DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_publication_requirements_check CHECK (publication_status <> 'published' OR (validation_status='validated' AND category <> 'pending' AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND length(trim(name)) > 0 AND physical_category IS NOT NULL AND physical_category <> 'unknown' AND ((category IN ('zee','zfi') AND legal_source_id IS NOT NULL) OR jsonb_array_length(source_evidence)>0)))"); + } +}; diff --git a/database/migrations/2026_10_06_220000_add_company_purchase_business_modality.php b/database/migrations/2026_10_06_220000_add_company_purchase_business_modality.php new file mode 100644 index 00000000..1fd335d3 --- /dev/null +++ b/database/migrations/2026_10_06_220000_add_company_purchase_business_modality.php @@ -0,0 +1,47 @@ +replaceConstraint(self::MODALITIES); + } + + public function down(): void + { + if (DB::table('existing_businesses')->where('modality', 'company_purchase')->exists()) { + throw new RuntimeException('Cannot remove company_purchase while records use it.'); + } + + $this->replaceConstraint(array_values(array_filter(self::MODALITIES, fn (string $value): bool => $value !== 'company_purchase'))); + } + + /** @param list $values */ + private function replaceConstraint(array $values): void + { + if (DB::getDriverName() !== 'pgsql') { + throw new RuntimeException('This modality constraint migration requires PostgreSQL.'); + } + + $constraints = DB::select("SELECT conname FROM pg_constraint WHERE conrelid = 'existing_businesses'::regclass AND contype = 'c' AND conkey @> ARRAY[(SELECT attnum FROM pg_attribute WHERE attrelid = 'existing_businesses'::regclass AND attname = 'modality')]::smallint[] AND pg_get_constraintdef(oid) ILIKE '%company_sale%' AND pg_get_constraintdef(oid) ILIKE '%supply_opportunity%'"); + if (count($constraints) !== 1) { + throw new RuntimeException('Expected exactly one business modality constraint; inspect the schema before migrating.'); + } + + $name = str_replace('"', '""', $constraints[0]->conname); + DB::statement('ALTER TABLE existing_businesses DROP CONSTRAINT "'.$name.'"'); + $quotedValues = implode(',', array_map(fn (string $value): string => "'".$value."'", $values)); + DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT imz_existing_businesses_modality_check CHECK (modality IN ('.$quotedValues.'))'); + } +}; diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 3c202bc3..88173c8b 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -223,3 +223,95 @@ Pilot: synthetic, unseeded small-business idea using the public `/Simulador` pat | Computer/mobile journeys | Targeted frontend/API/PHP tests pass. | Real Edge/browser checks at desktop/mobile widths, idea/existing-business/incomplete-data save-recovery and validation are not completed because runner policy rejected launching `msedge.exe`. Browser gate stays open. | Evidence: frontend suite 128/128; isolated PostgreSQL 17/PHP 8.5.11 full PHP suite 255/255 (2,601 assertions); TypeScript check, production build (2,399 modules), PHPStan (0 errors), PHP request lint and `git diff --check` passed. Build reports only the optional `fontaine` fallback warning. No operational fixture or database was used; the temporary QA database was isolated on port 55441. + +## Revisão individual para preparação de publicação — 2026-10-06 + +Esta secção é a reconciliação corrente desta matriz para Documentos e Formulários, Negócios e Oportunidades, e Zonas. A base activa `investemoz` foi consultada em leitura; nenhuma publicação, classificação, autorização, fonte, localização ou estado operacional foi alterado. Os URLs de gestão abaixo são relativos a `http://localhost:8000` e exigem a sessão/permissão indicada pela aplicação. + +### Regras de publicação que efectivamente se aplicam + +- **Documentos:** a rota de gestão é `GET /management/portal-services`; não existe uma página GET individual por recurso. A acção é apresentada na lista e usa POST. O código exige ficheiro activo, classificação pública, direitos autorizados tanto no documento como no recurso, fonte identificada e scan limpo do mesmo SHA-256. Para legislação, a confirmação da fonte tem de ser `official_confirmed`. Estado de vigência (`legal_status`) é separado e pode permanecer `unknown`/`amended`; nesse caso, o documento não pode ser apresentado como consolidação ou regra actual. Antimalware limpo não decide direitos. +- **Negócios:** os dois registos observados são rascunhos confidenciais. O proprietário edita em `/investidor/negocios/{id}`. `/management/negocios` é uma fila, não uma ficha directa, e exclui `draft`; a política existente exige submissão do proprietário e encaminhamento para as funções designadas antes de revisão/aprovação/publicação. O gestor não pode transformar rascunho alheio em anúncio directamente. +- **Zonas:** `InvestmentZones::publish` exige revisor diferente do editor, fonte válida para ZEE/ZFI, identidade/categoria/descrição/regime coerentes e localização territorial pública. O check de esquema `investment_zones_publication_requirements_check` reforça a revisão independente. Nos registos actuais todos os `reviewer_id` são nulos; as 19 relações de localização estão pendentes. A falta de operador não é requisito para publicar uma zona oficialmente existente com operador desconhecido. +- **Oportunidades:** os seis registos já estão `published`, mas não têm `last_validated_at`, fonte, proponente identificado ou evento de origem na tabela. Não foi tomada qualquer acção para os retirar, validar ou republicar. + +### Documentos — quatro recursos, revisão separada por dimensão + +| ID recurso / ID documento | Título e URL de gestão | Origem efectivamente identificada | Autenticidade | Vigência/actualidade | Direitos de divulgação / autorização | Bloqueio e proposta individual | +|---|---|---|---|---|---|---| +| `1` / `5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2` | Lei n.º 22/2022, alteração ao CIVA (republicação). `http://localhost:8000/management/portal-services` — localizar recurso 1. | [INM](https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da); catálogo/WebDAV oficial AT; tamanho e SHA-256 coincidem com a cópia oficial registada. | Confirmada para a edição e bytes. | Marcada `amended`; art. 17(1) foi localizado, mas não é consolidação integral actual. Não bloqueia publicação como documento histórico se assim rotulado; bloqueia tratá-la como texto actual consolidado. | Documento e recurso `rights_status=unconfirmed`, classificação `internal`; autorização para redistribuir esta cópia PDF/fac-símile não documentada. | **Não pronta.** Gestor regista base de reutilização/divulgação, depois classifica explicitamente como pública e publica. Proposta: Lei 22/2022 histórica, com aviso de alterações posteriores, nunca como CIVA consolidado. Fonte e malware passam; direitos bloqueiam publicação. | +| `2` / `64c46765-96b9-4f43-afc6-f5b730c010c4` | BR n.º 248/2025, Suplemento n.º 1. `http://localhost:8000/management/portal-services` — localizar recurso 2. | [Ficha INM](https://inm.gov.mz/pt-br/content/suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie); WebDAV oficial AT; 440.018 bytes e SHA-256 coincidem com a edição oficial registada. | Confirmada para a edição e bytes. | Contém seis diplomas distintos (Leis 7–12/2025); situação `unknown`. Citações/artigos localizados no PDF, mas não há uma vigência única para o boletim. | Direitos do ficheiro composto `unconfirmed`, classificação `internal`; autorização de redistribuição integral não registada. | **Não pronta.** Gestor documenta direitos do boletim composto. Proposta: publicar como edição oficial do BR com aviso de que os seis diplomas não são uma regra única ou consolidação. Não activar cálculos pela associação temática. | +| `3` / `58abf343-e1f5-446d-a9cb-3255f376ece9` | BR n.º 89/2017, I Série — aviso de rectificação da Lei 13/2016. `http://localhost:8000/management/portal-services` — localizar recurso 3. | [Ficha INM](https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569) identifica o aviso na p. 569. A página respondeu HTTP 200, mas não expõe ligação para PDF integral; só a ficha geral do BR. | **Binário local não autenticado:** hash local conhecido, sem comparação a exemplar oficial. Texto local refere alteração do art. 6(5), eliminação do art. 14(2)(c) e rectificação dos anexos I–III do art. 9; não autentica o PDF. | Efeito temporal e anexos rectificados não foram cotejados com cópia autêntica; `legal_status=unknown`. | Direitos `unconfirmed`, classificação `internal`. | **Não pronta.** Necessária cópia oficial dos bytes e anexos, comparação/hash e decisão sobre divulgação do fac-símile. A busca confirmou a ficha, não encontrou o binário. Depois, gestor decide direitos/publicação. | +| `4` / `b0ea969a-30ec-477b-8b8b-b7af82a11bfe` | Codificador DPA, INE, edição 07-06-2012. `http://localhost:8000/management/portal-services` — localizar recurso 4. | [PDF oficial INE](https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d); respondeu HTTP 200 e `application/pdf`; hash local coincide com o PDF oficial previamente descarregado. | Confirmada para a edição e bytes. | Edição de 2012; actualidade da nomenclatura/códigos requer cotejo com o mestre territorial vigente. | Direitos do artefacto PDF `unconfirmed`, classificação `internal`. | **Não pronta.** Gestor documenta direitos. Proposta: referência histórica datada; manter aviso da edição 2012 e não usá-la como prova automática da actualidade territorial. | + +Estado técnico comum dos quatro: ficheiros `active`, ClamAV 1.5.4 `clean`, hash analisado igual ao guardado, nenhum publicado. A página de gestão não é uma rota individual: `/management/portal-services/resources/{id}` não é GET e não deve ser usada como ligação de detalhe. + +### Negócios existentes — dois rascunhos privados + +| ID | Título armazenado / modalidade | URL de gestão e de proprietário | Origem, conteúdo conhecido e faltas | Bloqueio/regra; decisão proposta | +|---|---|---|---|---| +| `7208ef2b-0693-49ba-b0e2-9e52938b1a99` | Título privado guardado: “privado”; modalidade `mining_license`; `public_title=NULL`; estado `draft`; classificação `confidential`. | Gestão: `http://localhost:8000/management/negocios` (fila; rascunho não aparece). Não há GET administrativo individual. Ficha privada do proprietário: `http://localhost:8000/investidor/negocios/7208ef2b-0693-49ba-b0e2-9e52938b1a99`. | `source_system` e `legacy_id` nulos; payload só tem título e versão/etapa do assistente. Não há fonte, requerente/proponente, descrição de licença, localização, entidade emissora, referência/licença, actividade, validade ou autorização de divulgação. | Não é publicável como oportunidade: o registo é rascunho confidencial e não há conteúdo suficiente para descrever uma licença ou provar a autorização do proponente. Não há bloqueio por falta de operador de uma zona; trata-se de outra categoria. **Decisão do proprietário necessária:** identificar o que “privado” designa e fornecer fonte/autorização antes de submeter. Nenhuma publicação proposta agora. | +| `f65c1d9d-5820-4030-8dc1-fe7ff3de0cd2` | Título privado guardado: “venda de empresa”; modalidade `company_sale`; `public_title=NULL`; estado `draft`; classificação `confidential`. | Gestão: `http://localhost:8000/management/negocios` (fila; rascunho não aparece). Não há GET administrativo individual. Ficha privada do proprietário: `http://localhost:8000/investidor/negocios/f65c1d9d-5820-4030-8dc1-fe7ff3de0cd2`. | `source_system` e `legacy_id` nulos; payload só contém título e versão/etapa. Sem empresa identificada, descrição, proponente, mandato quando aplicável, origem da informação, localização, modalidade da transacção, valor/moeda e autorização específica para divulgar. | O estado privado e o fluxo de revisão são controlos existentes. **Decisão do proprietário necessária:** completar a ficha e declarar a autoridade para divulgar. Só depois da submissão entram na fila e nas etapas de revisão. Não publicar agora. | + +### Oportunidades já públicas — seis registos; origem de demonstração identificada + +Todos os seis registos correspondem exactamente ao conjunto fixo de `database/seeders/OpportunitySeeder.php`: títulos, referências, províncias, valores e impactos coincidem; todos usam `promoter_name="Promotor em validação"`, a mesma data de criação/publicação `2026-09-24 19:02:25`, `last_validated_at=NULL` e `geography_scope=unspecified`. A tabela `opportunities` não guarda `source_system`, actor de importação, documento de origem nem autorização do proponente. Assim, **a origem do conteúdo é um conjunto de demonstração definido no seeder; não é possível provar pela base qual processo executou/importou cada linha**. Não foram removidos nem alterados. + +| ID / referência | Título | URL de gestão existente | Informação presente; falta individual | Estado e acção proposta | +|---|---|---|---|---| +| `1` / `INV-MZ-AGR-001` | Unidade de processamento agrícola em Nampula | `http://localhost:8000/management/opportunities/1/context` (edita só contexto; não é gestão editorial/publicação da oportunidade) | Sector agro-negócio, Nampula, USD 2.500.000, impacto textual e maturidade 4; falta fonte comercial, promotor/autorização, data de validação e localização abaixo da província. | Já pública; não há proposta de republicação. Decisão humana: confirmar que deve permanecer como exemplo visível ou entregar evidência de uma proposta real autorizada. | +| `2` / `INV-MZ-ENE-002` | Mini-rede solar para polo produtivo | `http://localhost:8000/management/opportunities/2/context` (só contexto) | Sector energia, Zambézia, USD 1.200.000 e impacto textual; faltam fonte, proponente/consentimento, validação e localização exacta. | Já pública; mesma decisão individual: demonstrar como exemplo, ou fornecer origem real autorizada. | +| `3` / `INV-MZ-TUR-003` | Eco-lodge costeiro com integração comunitária | `http://localhost:8000/management/opportunities/3/context` (só contexto) | Turismo, Inhambane, USD 1.800.000 e impacto textual; faltam fonte, proponente/consentimento, validação e localização exacta. | Já pública; mesma decisão individual. | +| `4` / `INV-MZ-INF-004` | Centro logístico provincial | `http://localhost:8000/management/opportunities/4/context` (só contexto) | Infraestruturas, Sofala, USD 8.500.000 e impacto textual; faltam fonte, proponente/consentimento, validação e distrito/localização exacta. | Já pública; mesma decisão individual. | +| `5` / `INV-MZ-BLUE-005` | Processamento de pescado e cadeia fria | `http://localhost:8000/management/opportunities/5/context` (só contexto) | Economia azul, Zambézia, USD 900.000 e impacto textual; faltam fonte, proponente/consentimento, validação e localização exacta. | Já pública; mesma decisão individual. | +| `6` / `INV-MZ-FIN-006` | Plataforma de crédito para PME agrícolas | `http://localhost:8000/management/opportunities/6/context` (só contexto) | Mercado financeiro, Maputo Cidade, USD 3.000.000 e impacto textual; faltam fonte, entidade promotora/autorização, validação e localização. O título não prova autorização para conceder crédito/captar depósitos. | Já pública; mesma decisão individual. Não deve ser apresentada como operador autorizado de crédito sem prova própria. | + +O status `published` já está activo, mas “Promotor em validação” e a data de publicação não são verificação. A confirmação do responsável deve ser **por finalidade**: manter como conteúdo demonstrativo claramente identificado, ou substituir por evidência e autorização do proponente de cada oportunidade. Nenhuma acção em massa foi feita. + +### Zonas em rascunho — 16 registos + +Situação comum observada: `publication_status=draft`, `validation_status=validated`, `reviewer_id=NULL`. Existem 19 relações territoriais adicionais, todas `pending`. As localizações primárias abaixo correspondem a registos públicos do mestre territorial, mas a localização jurídica adicional não entra em pesquisa pública enquanto a relação estiver pendente. Para ZEE/ZFI, seis referências legais existentes estão `pending` e três registos não têm referência legal estruturada. Para parques, existe fonte HTTPS em `source_evidence`; a fonte pode ser promocional/institucional e os claims têm de ficar limitados ao que a página sustenta. O código permite a revisão administrativa por gestor diferente do autor; não exige operador nem certificador externo. + +| ID / código | Título, localização e URL de gestão | Origem e alcance efectivamente registado | Falta exacta; bloqueio; proposta individual | +|---|---|---|---| +| `1b57649a-f522-4cd5-8e97-32e82b6e1b37` / `MOZ-BELULUANE-PARK` | Parque Industrial de Beluluane — Matola Rio, Boane. `http://localhost:8000/management/investment/zones/1b57649a-f522-4cd5-8e97-32e82b6e1b37` | [APIEX — Beluluane](https://apiex.gov.mz/beluluane-industrial-park-free-zone/); referência institucional, não comprova regime, incentivos ou disponibilidade. Reconciliação local distingue complexo ~780 ha, parque 600 ha, Mozal 180 ha e 55 ha de ZFI; [MozParks](https://mozparks.co.mz/) também reporta área para arrendamento com âmbito diferente. | Relação Matola Rio pendente; perímetros e relação das áreas não reconciliados; não há confirmação de lotes disponíveis. Sem revisor. **Proposta:** pode ser considerado para publicação informativa depois de o gestor confirmar existência/escopo e publicar sem declarar área disponível; manter sem operador se desconhecido. | +| `d25d4eb1-744a-42e1-943a-47d33019c31c` / `MOZ-BELULUANE-ZFI` | Zona Franca Industrial de Beluluane — Matola Rio, Boane. `http://localhost:8000/management/investment/zones/d25d4eb1-744a-42e1-943a-47d33019c31c` | [APIEX — Beluluane](https://apiex.gov.mz/beluluane-industrial-park-free-zone/); referência à Resolução Interna do Conselho de Ministros n.º 15/99, 12-10-1999. | Artigo/efeito legal actual e fonte oficial primária ainda pendentes; `investment_legal_sources.validation_status=pending`; relação territorial pendente; sem revisor. **Não pronta.** Revisor confirma texto e vigência, valida território e só então gestor decide publicação. | +| `0d216b6d-598c-4672-be2a-294e5e924da5` / `MOZ-CDP-ANCUABE` | Parque Industrial de Ancuabe — distrito Ancuabe, Cabo Delgado. `http://localhost:8000/management/investment/zones/0d216b6d-598c-4672-be2a-294e5e924da5` | [MozParks — iniciativa de Cabo Delgado](https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/); suporta associação distrital na iniciativa provincial, não instalação operacional independente. | Fonte de promotor, não primária oficial; relação Ancuabe pendente; estado planeado, sem área/operação/disponibilidade confirmadas; sem revisor. **Não pronta** segundo o requisito de fonte oficial. Decisão: obter fonte oficial adequada ou manter rascunho; se publicar no futuro, rotular “planeado/iniciativa”, não parque operacional. | +| `ed4d2fba-95d5-42e3-8ca7-1f520de1036a` / `MOZ-CDP-BALAMA` | Parque Industrial de Balama — distrito Balama, Cabo Delgado. `http://localhost:8000/management/investment/zones/ed4d2fba-95d5-42e3-8ca7-1f520de1036a` | [MozParks — iniciativa de Cabo Delgado](https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/); sustenta associação à iniciativa, não operação individual. | Fonte oficial individual em falta; relação Balama pendente; sem área, operação ou disponibilidade; sem revisor. **Não pronta**; decisão individual igual à de Ancuabe, sem publicação em massa. | +| `aafbbac1-e8d8-4f33-8f93-d0acedc1ff95` / `MOZ-CDP-MONTEPUEZ` | Parque Industrial de Montepuez — distrito Montepuez, Cabo Delgado. `http://localhost:8000/management/investment/zones/aafbbac1-e8d8-4f33-8f93-d0acedc1ff95` | [MozParks — iniciativa de Cabo Delgado](https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/); associação distrital, não operação individual. | Fonte oficial individual em falta; relação Montepuez pendente; sem área/operação/disponibilidade; sem revisor. **Não pronta**; obter evidência específica ou manter rascunho. | +| `4b002994-b11c-4adf-80f3-12966bf669cb` / `MOZ-CDP-PALMA` | Parque Industrial de Palma — distrito Palma, Cabo Delgado. `http://localhost:8000/management/investment/zones/4b002994-b11c-4adf-80f3-12966bf669cb` | [MozParks — iniciativa de Cabo Delgado](https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/); associação distrital, não operação individual. | Fonte oficial individual em falta; relação Palma pendente; sem área/operação/disponibilidade; sem revisor. **Não pronta**; obter evidência específica ou manter rascunho. | +| `59fe5844-44de-4fd6-8968-9d23f80a77bb` / `MOZ-CRUSSE-JAMALI-ZEE` | Zona de Estância de Turismo Integrado de Crusse e Jamali — Matibane, Mossuril. `http://localhost:8000/management/investment/zones/59fe5844-44de-4fd6-8968-9d23f80a77bb` | [INM — BR 70/I Série, p. 571](https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-70-de-300813-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-571); Decreto n.º 47/2013, 30-08-2013. A página respondeu HTTP 200. | Artigo e efeito legal marcados pendentes no registo; fonte jurídica pendente; relação de âmbito jurídico Matibane pendente; sem revisor. **Não pronta.** Revisor confere decreto/âmbito e vigência; a ausência de operador não é bloqueio. | +| `30f38ba2-3a55-4cb8-8c7b-97a0445c5614` / `MOZ-MANGA-MUNGASSA-ZEE` | Zona Económica Especial de Manga-Mungassa — Cidade da Beira, Sofala. `http://localhost:8000/management/investment/zones/30f38ba2-3a55-4cb8-8c7b-97a0445c5614` | [APIEX — Manga-Mungassa](https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/) e Decreto n.º 22/2012, 06-06-2012. | Artigo/efeito legal pendente; URL APIEX não respondeu nesta sessão; fonte legal permanece pending; relação territorial pendente; sem revisor. **Não pronta.** Revisor confirma o acto em fonte primária acessível; publicação não depende de operador identificado. | +| `e67a94bc-7d75-4c03-8b66-0bbe7ad034bf` / `MOZ-MINHEWENE-ZFI` | Zona Franca Industrial de Minhewene — Nampula (apenas província). `http://localhost:8000/management/investment/zones/e67a94bc-7d75-4c03-8b66-0bbe7ad034bf` | [APIEX — Minhewene](https://apiex.gov.mz/zona-franca-industrial-de-minhewene/), declara identidade, província e área e aponta Decreto n.º 51/2011; a fonte foi inacessível nesta verificação. | Não existe `legal_source_id` para o diploma; localização mais precisa que província por confirmar; relação territorial pendente; sem revisor. **Não pronta.** Criar/validar referência ao Decreto 51/2011 e confirmar território antes de publicar como ZFI. | +| `74c5dab4-afa7-4f36-8f75-7df0a900b4fe` / `MOZ-MOCUBA-ZEE` | Zona Económica Especial de Mocuba — distrito Mocuba, Zambézia. `http://localhost:8000/management/investment/zones/74c5dab4-afa7-4f36-8f75-7df0a900b4fe` | [INM — BR 46/I Série, p. 1259](https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-46-de-060614-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1259), Decreto n.º 28/2014. A página respondeu HTTP 200. | Artigo/efeito legal por confirmar; relações Mocuba e Munhamade (duas localizações distintas) pendentes; fonte jurídica pendente; sem revisor. **Não pronta.** Rever as duas localizações contra o diploma separadamente; não fundir com a ZFI de Mocuba. | +| `530e738e-8b9a-429c-b00e-e5135394acd4` / `MOZ-MOCUBA-ZFI` | Zona Franca Industrial de Mocuba — Cidade de Mocuba. `http://localhost:8000/management/investment/zones/530e738e-8b9a-429c-b00e-e5135394acd4` | [APIEX — Mocuba](https://apiex.gov.mz/zif/mocuba-free-industrial-zone/); Decreto n.º 29/2014, 06-06-2014. | Fonte APIEX inacessível nesta sessão; diploma/artigo e vigência pendentes; relação territorial pendente; fonte legal pendente; sem revisor. **Não pronta.** Rever diploma separado da ZEE, confirmar território e só depois publicar. | +| `dc76a5f7-b1ba-4c5d-a9e3-66d1046be78d` / `MOZ-MUNHAVA-PARK` | Parque Industrial e Logístico de Munhava — Cidade da Beira, Sofala. `http://localhost:8000/management/investment/zones/dc76a5f7-b1ba-4c5d-a9e3-66d1046be78d` | [SDUBeira — Parque Industrial e Logístico](https://sdubeira.co.mz/project/parque-industrial-logistico/); página localmente reconciliada descreve existência/localização e desenvolvimento; pedido HEAD devolveu HTTP 403 nesta sessão. | A fonte reporta 900 ha e 990 ha com âmbitos divergentes; piloto de 50 ha não prova terreno disponível. Relação territorial pendente, sem revisor. **Não pronta.** Resolver âmbito de área e restringir descrição a desenvolvimento reportado; disponibilidade fica “não informada”. | +| `e54d7fb5-5efa-40a3-b145-2827c2a74b2d` / `MOZ-NACALA-ZEE` | Zona Económica Especial de Nacala — Nacala-Porto, província de Nampula. `http://localhost:8000/management/investment/zones/e54d7fb5-5efa-40a3-b145-2827c2a74b2d` | [APIEX — Nacala](https://apiex.gov.mz/nacala-special-economic-zone/); Decreto n.º 76/2007, 18-12-2007. | Fonte jurídica pendente; Nacala-Porto e Nacala-a-Velha estão ambas pendentes como âmbito legal; sem revisor. APIEX inacessível nesta sessão. **Não pronta.** Conferir Decreto 76/2007 e decidir, com evidência, se ambas as localizações pertencem ao âmbito; não as promover por associação automática. | +| `e2e44a13-bb19-48cb-b3da-d716eb6c189b` / `MOZ-REVUBUE-ZFI` | Zona Franca Industrial de Revúbuè — território primário Chiuta, Tete. `http://localhost:8000/management/investment/zones/e2e44a13-bb19-48cb-b3da-d716eb6c189b` | [APIEX — Revúbuè](https://apiex.gov.mz/revubue-free-industrial-zone/) reporta identidade, distritos e área; aponta Decreto n.º 41/2016. | Falta registo estruturado do diploma em `investment_legal_sources`; Chiuta e Moatize estão ambas pendentes; regime/perímetro por confrontar com o Decreto; fonte APIEX inacessível nesta sessão; sem revisor. **Não pronta.** Acrescentar/confirmar referência do Decreto e rever dois distritos separadamente. | +| `0ae14c9a-1adb-4ee7-8094-d5d54d4e75f1` / `MOZ-TOPUITO-PARK` | Parque Industrial de Topuito — território primário Nampula (província; Larde não existe no mestre territorial). `http://localhost:8000/management/investment/zones/0ae14c9a-1adb-4ee7-8094-d5d54d4e75f1` | [MozParks — início de construção do SME Village](https://mozparks.co.mz/construction-begins-on-sme-village-at-topuito-industrial-park/); suporta desenvolvimento, não disponibilidade operacional. Página respondeu HTTP 200. | Localização só provincial; Larde precisa de correspondência territorial documentada. Áreas de 200/215 ha divergem e 30 ha desenvolvidos não são disponibilidade. Relação territorial pendente; sem revisor. **Não pronta** para anúncio de lotes; pode ser proposta informativa após revisão individual com âmbito e construção claramente limitados. | +| `1f442439-fd9a-410c-b1a7-78c4332d31a0` / `MOZ-TOPUITO-ZFI` | Zona Franca Industrial de Topuito — território primário Nampula (a fonte situa-a em Topuito, distrito de Larde). `http://localhost:8000/management/investment/zones/1f442439-fd9a-410c-b1a7-78c4332d31a0` | [INM — BR 190/I Série, Decreto 57/2023](https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-2247), art. 1 indicado como candidato; o URL actualmente guardado no registo devolve 404. A ficha oficial identifica criação na localidade de Topuito, distrito de Larde, província de Nampula. | Não existe referência legal estruturada; URL armazenado precisa correcção; Larde falta no mestre territorial; relação territorial pendente; sem revisor. **Não pronta.** Correcção técnica da URL e referência legal, seguida de decisão humana sobre o mapeamento territorial; não atribuir Larde por aproximação. | + +### QA, estados de conclusão e sequência concreta + +- QA isolado, porta 55441 / `investmoz_test`: `ExistingBusinessWizardTest`, `ExistingBusinessIntakeTest`, `ExistingBusinessFoundationTest` e `BusinessMarketplacePublicTest` — **19 testes, 330 asserções aprovados**; incluem filtros/lista/ficha/manifestação sintéticos para `company_sale`, `real_estate`, `land_right` e cadastro privado separado de objectos `company`, `property`, `land_right`. Dados de QA apenas; nada foi inserido na base activa. +- O formulário privado usa um wizard comum com tipos de objectos separados. A ficha pública é genérica: apresenta modalidade, descrição/objectivo e campos publicados; não mostra os detalhes do objecto privado. Portanto a cobertura de cadastro e tipos está **testada em QA**, mas formulários e fichas públicas específicas para imobiliária e terrenos/direitos continuam **parciais**. Não alegar que um filtro prova estes percursos completos. +- As quatro fontes documentais têm links da biblioteca para requisitos/evidência; ligações pendentes não são mostradas como fundamento confirmado. O endpoint oficial INM do BR 89 confirmou a ficha, mas não forneceu o PDF. O site APIEX não respondeu às tentativas HEAD desta execução; isso é indisponibilidade de acesso nesta sessão, não prova de invalidade da fonte. +- **Documentos:** nenhum pronto para publicação até decisão documentada de direitos; o BR 89 também precisa de autenticação do binário/anexos. +- **Negócios:** nenhum anúncio pronto; submissão dos proprietários e dados de origem/autoridade em falta. +- **Oportunidades:** seis já publicadas como seed de demonstração, sem validação de fonte/proponente; requerem decisão individual sobre finalidade de demonstração ou documentação de oportunidade real. Não retirar nem aprovar em massa. +- **Zonas:** nenhuma pronta hoje: revisores nulos e relações territoriais pendentes; ZEE/ZFI têm fontes pendentes ou ausentes. Existem candidatos informativos de parques, mas fonte/promotor e afirmações precisam da revisão prevista. +- Após as decisões/revisões, verificar no portal activo `GET /documentos` → ficha e `/documentos/{id}/download`; `GET /negocios?modality=...` → ficha → manifestação de interesse; e `/oportunidades?province_id=...&zee_id=...` ou `zfi_id`/`park_id` → ficha da zona, comparando os resultados com o âmbito territorial aprovado. Até lá, não existe evidência de pesquisa positiva pública para documentos, negócios ou zonas, e a verificação de download público ainda é impossível por desenho. + +## Autorização documental e acção de disponibilização — 2026-10-06 + +- O responsável autorizou consulta e download dos quatro documentos actualmente no acervo, excluindo credenciais, dados pessoais privados, contratos preenchidos e documentos empresariais confidenciais. A autorização não certifica autenticidade/vigência nem activa regras ou cálculos fiscais; também não abrange negócios, dossiers ou zonas. +- A autorização foi registada em `audit_events` como `source_system=user-instruction`, `external_id=document-repository-disclosure-authorization-2026-10-06-v1`, acção `public_disclosure_authorized`, `actor_user_id=NULL`. A proveniência identifica a instrução recebida nesta conversa; não atribui autoria a gestor. +- Backup activo criado em `C:\Users\macbook\AppData\Local\InvestMoz\Backups\investemoz-before-document-publication-20261006.dump` (1.590.471 bytes; arquivo custom PostgreSQL, 2.293 entradas). O arquivo foi restaurado numa base isolada `investemoz_restorecheck_20261006`; verificação encontrou 4 linhas em `documents` e 4 em `portal_resources`. A base temporária de restauro foi removida após a verificação. O backup permanece disponível para recuperação. +- Restrição existente preservada: a gestão exige sessão autenticada, email verificado e `can:manage-portal`. Não existe sessão de gestor disponível nesta execução e não foi usada conta/ID de gestor. A alteração recente que exigia `official_confirmed` para toda a publicação de legislação era um gate da implementação da biblioteca, não uma política global; foi removida para permitir consulta com origem identificada e limitação visível. Mantêm-se ficheiro activo, fonte https identificada, análise antimalware limpa e hash dos bytes igual ao hash analisado. Direitos/classificação são aplicados apenas ao âmbito autorizado. +- Foi preparada na gestão documental uma acção autenticada única: **“Aplicar autorização e disponibilizar documentos elegíveis”** em `/management/portal-services` (`POST /management/portal-services/disclosure-authorization/apply`). A acção usa o evento de autorização, valida cada ficheiro individualmente, aplica classificação pública/direitos autorizados e publica, preservando `source_confirmation` e `legal_status`; itens que falhem ficam pendentes com motivo. O utilizador autenticado fica registado apenas como executor da publicação; a decisão de autorização continua atribuída à instrução do utilizador. Repetir a acção é idempotente. +- Na mesma acção, as associações aos requisitos documentais ficam separadas de regras: Lei 22/2022 art. 17(1) tem citação conferida no exemplar oficial, mas a aplicação fiscal continua pendente; os seis actos identificados no BR 248/2025 e a rectificação do BR 89/2017 ficam como `review_status=pending`, com a lacuna artigo/cópia indicada. Ligações pendentes aparecem como evidência não confirmada. Não se altera o motor fiscal. +- A ficha/listagem apresenta aviso quando a origem está identificada mas não confirmada independentemente; `legal_status` continua visível e pendências em associações a requisitos continuam identificadas como não confirmadas. Nenhuma regra fiscal foi alterada. A ligação a requisito não foi promovida a confirmada sem citação conferida; as ligações existentes de documentos 1–3 permanecem evidência pendente. O Codificador INE não tem associação técnica a requisito fiscal. +- **Estado activo desta execução:** não foi alterada a publicação de nenhum documento. As quatro linhas continuam `published=false`, classificação interna e direitos por confirmar até um gestor elegível executar a acção na sessão autenticada. A causa é o controlo de gestão existente, não uma nova aprovação documental. O documento 3 pode ser disponibilizado com o aviso de autenticidade da cópia; não pode ser apresentado como fonte oficial confirmada. As restantes situações jurídicas (alterado/histórico/desconhecido) também serão preservadas. +- Verificação: `php artisan test tests/Feature/PortalServicesTest.php` — 17 testes/497 asserções aprovados em QA; cobrem publicação autorizada com pesquisa textual e filtros combinados, ficha, download, associação ao módulo e links de requisitos com estados confirmados/pendentes. `npm.cmd run build` concluído. O build alerta sobre pacote opcional `fontaine` e chunk `services` acima de 500 KB; sem erro de compilação. Não houve browser autenticado disponível, portanto não se declara publicação, pesquisa ou download públicos concluídos. +- A autorização documental não foi aplicada a zonas, parques, negócios privados ou oportunidades de demonstração. A regra de publicação de parque não exige área confirmada; divergência de área, isoladamente, não é um gate técnico. Se a ficha avançar por decisão própria, deve omitir o total disputado ou mostrar os valores reportados com fonte/escopo, sem alegar disponibilidade. Beluluane, Munhava e Topuito requerem revisão por registo para fonte/localização pública e para retirar ou qualificar a área; Topuito só tem província associada no mestre geográfico, pelo que não se pode anunciar uma associação administrativa a Larde enquanto essa referência não existir. ZEE/ZFI continuam dependentes de acto oficial estruturado e validado, regime e relações territoriais apropriadas; uma área sem confirmação não substitui essas evidências. +- Proposta para as seis oportunidades de demonstração já públicas: preservar os registos e IDs, adicionar origem/tipo `demo` e identificá-los visivelmente como **Exemplo de demonstração**, separar a pesquisa de exemplos da pesquisa de oportunidades reais e excluir os exemplos dos resultados reais por defeito. Não os converter nem retirar em massa; cada registo real exigirá origem e proponente documentados. Nenhuma alteração foi feita nesta execução. diff --git a/docs/investmoz/document-corpus-inventory-2026-10-06.csv b/docs/investmoz/document-corpus-inventory-2026-10-06.csv new file mode 100644 index 00000000..628e79cc --- /dev/null +++ b/docs/investmoz/document-corpus-inventory-2026-10-06.csv @@ -0,0 +1,78 @@ +"RecordType";"Path";"FileName";"SizeBytes";"SHA256";"TitleOrSubject";"DuplicateGroup";"Disposition";"Status";"Notes" +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/62a3324a-541d-414e-8fa8-8cc25c41ded3/Pasted text.txt";"Pasted text.txt";"8048";"7EE3460C0FC278057FFB283AB33D556F58ED8D9834F420C76103F69A0E18FBE3";"9. Prompt para gerar as migrations em Laravel";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/65a3763e-afd7-4ba0-b650-1f4c37f3cfbb/Pasted text.txt";"Pasted text.txt";"11460";"DFF87AD6BC8D49D487641E0DD577314BF78F75C3D0220A8802461C5DA0C48C7F";"Actua como arquitecto de software sénior e programador full stack especializado em:";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/1b36f6f0-93cf-49aa-9479-986a57bd18f4/Pasted text.txt";"Pasted text.txt";"37098";"470913BEA520A8EE267F8DF27C0849E12E0632D0EDE2D4556ED463C994B82161";"Pasted text sem título legível na primeira linha";"1b36f6f0-93cf-49aa-9479-986a57bd18f4 + 27922f5b-f041-47db-bbf1-6bc50f3a83dc";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/27922f5b-f041-47db-bbf1-6bc50f3a83dc/Pasted text.txt";"Pasted text.txt";"37098";"470913BEA520A8EE267F8DF27C0849E12E0632D0EDE2D4556ED463C994B82161";"Pasted text sem título legível na primeira linha";"1b36f6f0-93cf-49aa-9479-986a57bd18f4 + 27922f5b-f041-47db-bbf1-6bc50f3a83dc";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/602a2b85-7e0d-49a3-b048-de31d42fafc9/Pasted text.txt";"Pasted text.txt";"11118";"07BC673AA256F0CCC73138F70121077E948650683B2DD24F2EAD3DD75C76831B";"Actua como arquitecto sénior de inteligência artificial, engenheiro de machine learning e programador full stack especializado em:";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/a57fcca7-d9b3-45e9-96ab-575b1c5a3536/Pasted text.txt";"Pasted text.txt";"30350";"8E6DEAA7F0D738299B1AE3B8BF89B633641E0CC0CB5E7211461601C0C2B6D635";"Prompt Técnico — Unificação e Upgrade do Portal do Investidor";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/23b033e6-9ee3-4a6c-a41b-688fe7f09795/Pasted text.txt";"Pasted text.txt";"44742";"5DBF82C2D8BA2C9056B838C4565CB0DB9DE28DA4173075BFDC12B233B3C43FE9";"Implementação Integrada de ZEE, ZFI e Revisão do Catálogo de Investimentos";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/777f05aa-700e-4dd6-8dfe-e277214fb713/Pasted text.txt";"Pasted text.txt";"21531";"4A12DE1C0822EF8583CA5BC7F3D4FFA8DBBA1C44FBFBF4E0AADC345EFA696F65";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/5d9b9f23-04ef-4e14-89d5-bbe6abb4a06b/Pasted text.txt";"Pasted text.txt";"24256";"B3E17BB11D6EBAFA121B623C8FF8949812B35EF0CA5926A33C22F3E2093A01D9";"Prompt técnico de implementação — Mercado de Negócios Existentes do InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/7cb98fa0-99e1-4dcc-a3f1-30cfa71ea9ce/Pasted text.txt";"Pasted text.txt";"13591";"09933D0E63883E691A573EF23B7C13FA8FCF66556FE38618E222275F5E327C54";"InvestMoz — Modelo de Dados Geográficos, Plano de Implementação do MVP e APIs Territoriais";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/8a8b785e-862f-41bb-8d03-06fc6f1b853e/Pasted text.txt";"Pasted text.txt";"39222";"0755F56AE929FD37451D7DD589BED8E1F35C6655F2D674EC1BE788B2582CF31D";"InvestMoz — Modelo de Dados Geográficos, Plano de Implementação do MVP e APIs Territoriais";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/e11a00e4-8fcc-4ad8-8861-2fc0244c4324/Pasted text.txt";"Pasted text.txt";"23406";"C32107C1735D544D91C1C5E6F94C731E71871C66FF6DDCBECEDC4C86FD1630C7";"Prompt técnico de implementação — Mercado de Negócios Existentes do InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/e642d815-f449-495f-bd61-4b3e247e23de/Pasted text.txt";"Pasted text.txt";"9502";"5DFF89ADCE75A8B9E0A699BD31986A1A18FD3F25DE7218DA2800631365B4986E";"Prompt técnico completo — correcção definitiva da hierarquia e dos dropdowns de localização";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/9e57f88e-82d7-45a9-b1b3-4ae8cec7f713/Pasted text.txt";"Pasted text.txt";"11805";"F15768C79A22C039B9137B937DA003DD61912CD9E8BB9FD7A40C795DD0AF067E";"Prompt de Implementação da Cartografia";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/8865429e-0156-4167-b465-3595b0fda05f/Pasted text.txt";"Pasted text.txt";"5262310";"B2B4878730A3285D9FCACFF5236A15671802C172FF28C10704459AA2CFDFB9EB";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/bfb8dbf2-ecff-45be-8612-c1868ebe8343/Pasted text.txt";"Pasted text.txt";"6665";"CC874C1E0D7AB1D7779DF1664C524AE5F876EF7AF99B7FEC2358C4BE7D0E53C6";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/1dc38f3d-1455-4b18-8358-899a66386a7f/Pasted text.txt";"Pasted text.txt";"36260";"05EF8EC7DA01B185FF0872E917E9FA9C5C1E7407C3D547788F9AD53930DD5990";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/e51bbb68-0db4-41ba-84b3-e20754ba0d33/Pasted text.txt";"Pasted text.txt";"37962";"214044C4A4A71430AB8F271BA85256E48C6A08785DA0E48ECB7507A04F23B6D1";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/a2b1c919-0d1e-443b-bb5e-8917c877899d/Pasted text.txt";"Pasted text.txt";"54479";"06996499B4B5A6B84ABA61D43BCEBBE9BC7D710700BF279172DD92F1016C8BC6";"Mercado de Negócios Existentes do InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/b86ad3e6-37f4-4f04-9bbf-6dc33f69250e/Pasted text.txt";"Pasted text.txt";"6991";"A2AD18C729F00E37DB23C1B26615DCEE50960E0EED7346D6CC013E6A57C2C24D";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/890e42e3-ce0c-4cd0-903d-d97785be9ce5/Pasted text.txt";"Pasted text.txt";"68522";"D60559844425F03F4A722DFF3974BE91E8E4E279179BD58663AE164FCC9A68CC";"Mercado de Negócios Existentes do InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/5dbe0293-16ad-4975-8956-ea48778d6510/Pasted text.txt";"Pasted text.txt";"23804";"6E51CFCD4EE1329C51736E0FB1A47668A8E9550DE94EAD8FFC222BA22498179B";"Formulário de criação de oportunidade de negócio";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/2daec0e8-1c8a-4606-bb4a-b7fccd0ea7bf/Pasted text.txt";"Pasted text.txt";"0";"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855";;"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Texto de prompt/instrução, não binário documental." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/8528a9d2-b934-4bb3-a132-a18b7e13414d/Pasted text.txt";"Pasted text.txt";"30136";"6C2AE3E9B43133940DD90FA2BE0D24C07EED06ACE5799CEA3EFC25574C0A0E8E";"Script PostgreSQL/PostGIS das tabelas a implementar";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/bf1d310a-d3a8-48fc-953f-0648622a6c85/Pasted text.txt";"Pasted text.txt";"81460";"FD7878021AE5D18C9D4A9A79073552C36876D59726B8FB2612752F2594080CA0";"PROMPT MESTRE ";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/ce3b9157-6463-4c3f-af50-6d53cba78a23/Pasted text.txt";"Pasted text.txt";"30586";"6381A59FBE8BD8EE5A76D8755CEB320EF2D14FC1E628B8E1CBEF2C5EB83E30C8";"Instrução principal para o Codex";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/7235d6fa-a6a3-453f-a97c-b6741c083d10/Pasted text.txt";"Pasted text.txt";"16673";"2626DF4CA70D52CB5F90247AF7B6EEBBB251FC64B4915EE0ED1FBBB693CB3586";"Prompt de Validação da Actividade Realizada até ao Momento";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/22ddc8a3-c393-40ff-9960-dcf7bd9e34f6/Pasted text.txt";"Pasted text.txt";"8230";"5464CDAAB509637A44ADC08FE621FDE45A0BC16C8FC3AA9483FB1B9F0F9E0E85";"Prompt para o Codex — Execução do Plano de Remediação e Validação por Evidências";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/c1ac842e-1b98-4f18-82bf-5155dbddf288/Pasted text.txt";"Pasted text.txt";"11603";"BD55CAC92926FD804E358921924740EB9C7E649AF7A845FE85D2C3686AE2EF09";"Pasted text sem título legível na primeira linha";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Texto de prompt/instrução, não binário documental." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/d5886992-638e-479f-bc30-e327bd7a10ee/Pasted text.txt";"Pasted text.txt";"12246";"F0DA2B58AF2E30CC1B1CC353F3A6B00A592C800E17B8D00BD2C13DCBF16C5A78";"Estrutura proposta.";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/a1795b4c-c360-4e31-8eef-2daeb930721c/Pasted text.txt";"Pasted text.txt";"23694";"24F36A01C9B6D0894155CBF09C17F128904626935461897BF59CE2B811D08D9D";"Prompt completo para Codex/Copilot — React + Laravel + PostgreSQL/PostGIS: ";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/cfe4012e-cb54-4a42-8fcf-9567bea45d7d/Pasted text.txt";"Pasted text.txt";"34983";"8AB2B0DF6B41A483D72AA852984C627A1C41DEA3F05E0F13AC3EE0AD6D680B65";"Etapa 9 — Formulário React com dropdowns";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/50aa6a01-de6b-4fff-9625-46dbbebd033c/Pasted text.txt";"Pasted text.txt";"13287";"129DF97B64E3747D626B89E25F70BBA14AD50CCDB47F08EEEDEDD41D0509EBE0";"19.1 Prompt final para Codex";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/2368cafa-157f-4177-ac75-255563247749/Pasted text.txt";"Pasted text.txt";"16046";"300EDE994BA6CBACDF7FFEBB4CBD75777ECCB3F359842491404357E8A549B235";"Etapa 10 — Motor fiscal principal";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/ce71e769-044b-4eaa-9f8a-793e52cd644d/Pasted text.txt";"Pasted text.txt";"6128";"0A64D4FE9DC2D80211568D74CC49335472D7A2BE01A199223BF4B1A0FB58E983";"ID Regra/cenário e entradas Resultado esperado Prioridade";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/77627963-3e1c-4d41-add5-18eedbf62b01/Pasted text.txt";"Pasted text.txt";"16486";"62719366D33C647930CC4BC92A07D8E933C4F0E0DC06D91E2464DA86CA4B6F8A";"# Evolução sequencial do simulador multiuso da investeMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/ddd6740c-486e-4ebf-a11e-c9687206178a/Pasted text.txt";"Pasted text.txt";"7103";"149673C0E35573716086D72F65E1B8E6C805C4F890B44722B839D4CE7E6CAF15";"Formulário de cadastro de oportunidades de negócio da InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/13ef8ec2-be07-4b10-93a1-9fe134b928fa/Pasted text.txt";"Pasted text.txt";"14654";"8185BED1E45611AF8861C8F00568A7DA6901896E2650355C41642805485FFAE1";"Prompt master para revisão, correcção e implementação consolidada do InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/acb359eb-1a65-486b-8680-c6abeacccef3/Pasted text.txt";"Pasted text.txt";"14837";"99E36A08038F2540114AD87C32423CE9F0AA6C7C7AC866372273B4FD33DF3765";"Prompt consolidado para o InvestMoz — Terra DUAT formulários e acompanhamento";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/95cf3a5e-fdbf-4005-a877-57d4ac1f93c5/Pasted text.txt";"Pasted text.txt";"11261";"A2434658AD9E75E59F897CB57C4C2934B8A28F879E32584F3D00064900C6A5EE";"# Prompt master consolidado — Conclusão e melhoria do portal InvestMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/c6245408-7052-4478-8804-377b282e8203/Pasted text.txt";"Pasted text.txt";"30976";"600240DD64B59B82BDB92E471B475F9EFC96EAB67F9CD532A432E1E7A324F0C9";"Benchmark de melhorias do InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/a89743e5-3439-4543-9382-2df32e6ceaa9/Pasted text.txt";"Pasted text.txt";"6636";"574657F7FC612BF7760A31F200978B289A911DF39964B8C60E3B357845D8BD4D";"Correcção e integração funcional do portal InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/dd19a693-8805-4f81-a65c-bf2af031a37a/Pasted text.txt";"Pasted text.txt";"9346";"2453530CC98FFD9B270EEF47786A9D596701E4831E2A3C16C3765152CC916494";"Integração de ZEE, ZFI e parques industriais no InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/d53670c4-f46a-4d49-b188-770b86bdda19/Pasted text.txt";"Pasted text.txt";"8127";"C71EC7ADB5AF079FC5C93536107FE30FFC0906700B5EE5EEF6BD2CCA1665DFDE";"Integração dos parques industriais e ZFI no InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/aa68e897-14b6-4504-ad05-93c2e3f0b4fc/Pasted text.txt";"Pasted text.txt";"9536";"B207D14E53CF13E621A01D0E2BF4BB82BDB65AD0BA07C457B42923299D0336A9";"Parques Industriais e Zonas Francas Industriais em Moçambique";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/2fabbcdf-a098-4a0c-907f-b5cc428c4608/Pasted text.txt";"Pasted text.txt";"12095";"991D4B34E6DADD23F4C66A61DE509FBAC1ABE83D8CBB316DD24BA400C6F13451";"Melhoria integrada do simulador InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/e1fa9a32-14ae-46f3-ac02-c7115c832175/Pasted text.txt";"Pasted text.txt";"8246";"55050F60A15D18B119C83799231A0AF34B00738F7AE8202A232A07DF23F65FD1";"Biblioteca integrada de Documentos e Formulários do InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/11c236f7-e954-499a-b290-3d6dce07543a/Pasted text.txt";"Pasted text.txt";"5158";"A8241744DB40871B36D0A2B39796B7168FADCF243E02A352FA794176DA389C95";"Pasted text sem título legível na primeira linha";"11c236f7-e954-499a-b290-3d6dce07543a + f003a1f1-4ca9-4193-9790-0ba7c1db9812";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/f003a1f1-4ca9-4193-9790-0ba7c1db9812/Pasted text.txt";"Pasted text.txt";"5158";"A8241744DB40871B36D0A2B39796B7168FADCF243E02A352FA794176DA389C95";"Pasted text sem título legível na primeira linha";"11c236f7-e954-499a-b290-3d6dce07543a + f003a1f1-4ca9-4193-9790-0ba7c1db9812";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/4b4f7002-adf3-4004-abb0-f389778dc08b/Pasted text.txt";"Pasted text.txt";"5601";"02F44BAFEA4BA47ADC8B24284D4B189AB9F1DB3F6C1D1D63FC06178C400F9EE0";"Migração e associação das fontes legais do InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/b93f36c6-ac2c-45f4-995d-577c09b3862e/Pasted text.txt";"Pasted text.txt";"10274";"9F6DD1D9C0CA8628443EDC69A6151BA49F96A8E044AC8AFACE249C4957DAFB86";"Conclusão da biblioteca Documentos e Formulários do InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Anexo de instrução";"C:\Users\macbook\.codex/attachments/006f3ea6-5bf4-4315-b3b6-5dda9c3a6566/Pasted text.txt";"Pasted text.txt";"6842";"DBF5AFDCA293302BB84389AC33AA576C25859F8FA54E26D0B9687B9C564BE005";"Recuperação funcional do InvesteMoz";"";"Preservar como histórico de instrução; não importar como diploma/documento da biblioteca";"Acessível";"Contém referências fiscais em texto de instrução; não é, por si, fonte jurídica." +"Fonte fiscal arquivada";"C:\laragon\www\portal-investidor\docs\investmoz\simulations\source-archive\BR-248-I-Suplemento-1-2025.pdf";"BR-248-I-Suplemento-1-2025.pdf";"440018";"CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251";"BR-248-I-Suplemento-1-2025";"";"Importado para documents/portal_resources; consulta pública pendente de acção de gestão";"Acessível / importado / privado";"SHA-256 registado e submetido a ClamAV; hash analisado coincide." +"Fonte fiscal arquivada";"C:\laragon\www\portal-investidor\docs\investmoz\simulations\source-archive\BR-89-I-Serie-2017-06-08.pdf";"BR-89-I-Serie-2017-06-08.pdf";"9456358";"11F0CAB15EB6E51010952203FFA24FDC533A24FD642E19525FE4F46B17A1F5E5";"BR-89-I-Serie-2017-06-08";"";"Importado para documents/portal_resources; consulta pública pendente de acção de gestão";"Acessível / importado / privado";"SHA-256 registado e submetido a ClamAV; hash analisado coincide." +"Fonte fiscal arquivada";"C:\laragon\www\portal-investidor\docs\investmoz\simulations\source-archive\Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf";"Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf";"1834284";"EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53";"Lei-22-2022-IVA-republicada-BR-250-2-suplemento";"";"Importado para documents/portal_resources; consulta pública pendente de acção de gestão";"Acessível / importado / privado";"SHA-256 registado e submetido a ClamAV; hash analisado coincide." +"Fonte de referência territorial";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-source-20260928\original.pdf";"INE-Codificador-Divisao-Politico-Administrativa-2012.pdf";"67067";"5C69B6F189A1D3F5307E0CF7DD8224554705158A682637D3105AFAD615DB26E2";"INE Codificador DPA edição 07-06-2012";"";"Importado para documents/portal_resources; consulta pública pendente de acção de gestão";"Acessível / importado / privado";"Fonte oficial identificada e hash coincidente; actualidade territorial e direitos do artefacto por rever." +"Fonte indicada, não acessível";"Legislacao/Lei 13_2016_Reimpressao Codigo do IVA.pdf";"Lei 13_2016_Reimpressao Codigo do IVA.pdf";"";"";"Lei 13/2016, republicação integral do CIVA";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Texto-base importante para consolidação; ficheiro não encontrado nos caminhos disponíveis; OCR anterior inconclusivo." +"Fonte indicada, não acessível";"Legislacao/RectificaçãoLei.13.2016_Republicação.Codigo.IVA.pdf";"RectificaçãoLei.13.2016_Republicação.Codigo.IVA.pdf";"";"";"CIVA rectificado / republicação, 15 páginas referidas em inventário anterior";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Versão distinta; não confundir com o aviso BR 89. Ficheiro não localizado neste workspace/perfil." +"Fonte indicada, não acessível";"Legislacao/alteracao CIVA 2023.pdf";"alteracao CIVA 2023.pdf";"";"";"Alteração CIVA 2023";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Ficheiro referido por inventário anterior, não localizado nesta execução; conteúdo/artigos ainda não reconciliados." +"Fonte indicada, não acessível";"Legislacao/Legislacao/Legislacao/Lei 22 2022 IVA.pdf";"Lei 22 2022 IVA.pdf";"";"";"Lei 22/2022 IVA, cópia de 1.989.462 bytes referida antes";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Versão/cópia diferente do exemplar AT arquivado (1.834.284 bytes); não deduplicar sem inspeccionar edição e hash; original não localizado." +"Fonte indicada, não acessível";"Legislacao/Lei 12.2025_IRPC.pdf";"Lei 12.2025_IRPC.pdf";"";"";"Lei 12/2025 IRPC, cópia de 511.828 bytes referida antes";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Pode sobrepor-se parcialmente ao BR 248; preservar como versão candidata até comparação de identidade e hash; ficheiro não localizado." +"Fonte indicada, não acessível";"Legislacao/Legislacao/2009/Lei 4_2009 Código dos Benefícios Fiscais - versão consolidada.pdf";"Lei 4_2009 Código dos Benefícios Fiscais - versão consolidada.pdf";"";"";"Lei 4/2009, Código dos Benefícios Fiscais";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Extração anterior parcial; ficheiro não localizado. Não associar automaticamente a incentivo executável." +"Fonte indicada, não acessível";"Legislacao/Legislacao/a digitalizar/ISPC_Financas.pdf";"ISPC_Financas.pdf";"";"";"ISPC_Financas.pdf";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Scan/camada de texto insuficiente segundo inventário anterior; ficheiro não localizado nesta execução." +"Fonte indicada, não acessível";"Informacao Publica/Legislacao - resumos/Reforma fiscal de 2026.docx";"Reforma fiscal de 2026.docx";"";"";"Reforma fiscal de 2026 (resumo secundário)";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Já foi lido e reconciliado como fonte secundária em relatório anterior; binário não está nos caminhos acessíveis agora; não é autoridade primária." +"Fonte indicada, não acessível";"Caminho não registado no dossier actual / CSV de revisão pessoal";" CSV de revisão pessoal";"";"";"CSV de revisão pessoal ligado à revisão fiscal";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Nome/caminho/hash não constam do registo acessível; não localizado. Precisa caminho de origem para inventário por ficheiro." +"Fonte indicada, não acessível";"Caminho não registado / Modelo 20 OCAM";" Modelo 20 OCAM";"";"";"Declaração anual de informação contabilística e fiscal, exercício 2025";"";"Pendente de localização/recepção; não criar ficheiro fictício";"Inacessível nesta execução";"Referida como documento empresarial com dados fiscais privados; fora da biblioteca pública. Ficheiro não acessível neste workspace; manter fora do acervo público." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928\original.pdf";"original.pdf";"67067";"5C69B6F189A1D3F5307E0CF7DD8224554705158A682637D3105AFAD615DB26E2";"original";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"Duplicate private copy of INE source; reconcile by SHA-256";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\layout.txt";"layout.txt";"57268";"783A7886ED5A27B8F9D0063A787928E9A4B09723C28A260C019C2DE215A670A3";"layout";"";"Derived extraction/layout; not legal source";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\original.pdf";"original.pdf";"67067";"5C69B6F189A1D3F5307E0CF7DD8224554705158A682637D3105AFAD615DB26E2";"original";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"Duplicate private copy of INE source; reconcile by SHA-256";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\owner-acceptance-242d74394061d175e70e56161a48f66f02cce8d5989fc61098599d082da38f60.json";"owner-acceptance-242d74394061d175e70e56161a48f66f02cce8d5989fc61098599d082da38f60.json";"808";"242D74394061D175E70E56161A48F66F02CCE8D5989FC61098599D082DA38F60";"Artefacto de revisão/proveniência; conteúdo privado não reproduzido";"";"Private review/provenance artefact; do not publish";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\report.json";"report.json";"1115";"F455D6D995E7A970F9F7DC0CAAC58DEE46E5C5D60031D143CADB840741341BCA";"Artefacto de revisão/proveniência; conteúdo privado não reproduzido";"";"Private review/provenance artefact; do not publish";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\review.csv";"review.csv";"82109";"3F80D270B67CCBB3D8A2900A8CC847AC2BFEDBA4A9A2867C47FD5DBF75DD6A70";"Artefacto de revisão/proveniência; conteúdo privado não reproduzido";"";"Private review/provenance artefact; do not publish";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-source-20260928\layout.txt";"layout.txt";"57191";"2FB8EBE44BB0F9E22E382436890A26822592E1DA023B56CA392E9AAFDDE7D85A";"layout";"";"Derived extraction/layout; not legal source";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\58abf343-e1f5-446d-a9cb-3255f376ece9";"58abf343-e1f5-446d-a9cb-3255f376ece9";"9456358";"11F0CAB15EB6E51010952203FFA24FDC533A24FD642E19525FE4F46B17A1F5E5";"58abf343-e1f5-446d-a9cb-3255f376ece9";"";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2";"5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2";"1834284";"EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53";"5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2";"";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\64c46765-96b9-4f43-afc6-f5b730c010c4";"64c46765-96b9-4f43-afc6-f5b730c010c4";"440018";"CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251";"64c46765-96b9-4f43-afc6-f5b730c010c4";"";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." +"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"67067";"5C69B6F189A1D3F5307E0CF7DD8224554705158A682637D3105AFAD615DB26E2";"b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão." diff --git a/docs/investmoz/document-corpus-reconciliation-2026-10-06.md b/docs/investmoz/document-corpus-reconciliation-2026-10-06.md new file mode 100644 index 00000000..a2021423 --- /dev/null +++ b/docs/investmoz/document-corpus-reconciliation-2026-10-06.md @@ -0,0 +1,97 @@ +# Reconciliação do acervo documental e fontes fiscais + +Data: 06-10-2026. Inventário limitado aos anexos enumerados no manifesto local do Codex, aos arquivos documentais do repositório, às referências de fontes fiscais já registadas e ao armazenamento privado que serve esses registos. O índice completo por caminho, tamanho, SHA-256, destino e estado encontra-se em [document-corpus-inventory-2026-10-06.csv](document-corpus-inventory-2026-10-06.csv). + +## Reconciliação quantitativa + +| Medida | Resultado desta revisão | +|---|---:| +| Anexos enumerados no manifesto | 52 ficheiros `.txt` | +| Conteúdos de anexo únicos por SHA-256 | 50 | +| Pares de anexos exactamente duplicados | 2 pares: `1b36f6f0…` / `27922f5b…` e `11c236f7…` / `f003a1f1…` | +| Caminhos de ficheiros acessíveis inventariados | 67 | +| Conteúdos únicos por SHA-256 entre esses caminhos | 59 | +| Documentos-fonte substantivos únicos acessíveis | 4 | +| Documentos binários de fonte actualmente acessíveis | 4 documentos distintos: Lei 22/2022, BR 248/2025, BR 89/2017 e Codificador DPA do INE | +| Cópias/derivados privados adicionais inventariados | 11 objectos, incluindo cópias dos quatro PDFs, extracções e artefactos internos de revisão | +| Registos documentais importados na base activa | 4 documentos e 4 recursos; todos com scan limpo e hash do scan igual ao hash armazenado | +| Publicados para consulta pública | 0 | +| Privados na biblioteca activa | 4 | +| Fontes anteriormente referidas mas inacessíveis nesta execução | 10 entradas, cada uma listada no CSV com caminho/nome tal como registado e pendência concreta | +| Registos no CSV de inventário | 77 objectos/entradas; inclui anexos, originais, cópias, derivados e fontes inacessíveis | + +Os 52 anexos são instruções, prompts, texto de terminal/SQL e outros conteúdos colados; não são 52 diplomas. Foram preservados como histórico, fora da biblioteca pública. Dois pares têm bytes idênticos; as restantes cópias mantêm-se individualizadas. Os 59 hashes únicos incluem 50 textos de instrução, quatro fontes substantivas e cinco conteúdos auxiliares únicos; cópias privadas não contam como documentos novos. O CSV não copia o conteúdo potencialmente privado dos anexos ou dos artefactos de revisão. + +Os 10 itens inacessíveis são as sete fontes antigas referidas por caminho relativo `Legislacao/...` (CIVA republicado de 2016; cópia do CIVA rectificado; alteração CIVA 2023; outra cópia de Lei 22/2022; Lei 12/2025; Código dos Benefícios Fiscais Lei 4/2009; `ISPC_Financas.pdf`), o resumo `Informacao Publica/Legislacao - resumos/Reforma fiscal de 2026.docx`, o CSV de revisão pessoal cujo nome/caminho não ficou registado e o Modelo 20 OCAM. Pesquisas nos directórios Documents, Downloads, Desktop e OneDrive pelos nomes conhecidos não localizaram os binários. O Modelo 20 é tratado como informação empresarial confidencial, não como documento para publicação. Para os restantes, o destino é pendente de localização/recepção, sem fabricar substitutos. Os nomes e caminhos de origem disponíveis estão individualizados no CSV. + +## Lote documental e estados independentes + +O lote real de quatro fontes foi previamente importado para a biblioteca activa, com cópia privada, hash e análise antimalware. A pré-visualização repetida de `php artisan documents:import-legal-source-archive` nesta execução encontrou os quatro hashes já registados e devolveu `reused/reconciled` para cada um; terminou com “Preview only; no files or database rows were changed.” Não criou cópias nem alterou a base activa. + +| Recurso | Disponibilidade | Situação documental | Uso fiscal | +|---:|---|---|---| +| 1 — Lei n.º 22/2022, CIVA republicado | Privado; consulta pública pendente da acção autenticada de disponibilização | Fonte oficial e bytes confirmados; diploma alterador; edição/data visíveis; não equivale a consolidação integral actual | Art. 17(1) citado como evidência documental; não activa taxa nem cálculo | +| 2 — BR n.º 248/2025, Suplemento n.º 1 | Privado; consulta pública pendente da mesma acção autenticada | Edição oficial confirmada; contém seis diplomas distintos; vigência deve ser tratada por diploma/dispositivo | Citações ligadas às chaves técnicas como evidência; sem activação de regras | +| 3 — BR n.º 89/2017, aviso de rectificação | Privado; autorização anterior permite disponibilizar como cópia fornecida | A ficha INM identifica o aviso, mas os bytes/anexos da cópia não foram comparados com exemplar oficial; autenticidade da cópia não confirmada | Associação ao CIVA fica pendente; não é fundamento confirmado | +| 4 — Codificador DPA INE, edição 07-06-2012 | Privado; consulta pública pendente da mesma acção autenticada | PDF comparado com exemplar servido pelo INE; actualidade territorial e direitos do artefacto PDF não determinam a autenticidade da edição | Sem uso como fonte fiscal; não usar automaticamente como cadastro territorial actual | + +A autorização anterior está registada com proveniência `user-instruction`, sem atribuição a gestor fictício, e abrange os recursos 1–4. Não exige nova aprovação genérica. O mecanismo de publicação permanece protegido por autenticação, email verificado e permissão de gestão. A acção preparada está em `/management/portal-services`, botão **“Aplicar autorização e disponibilizar documentos elegíveis”**; cada item é verificado individualmente, o executor autenticado fica registado e nenhuma regra fiscal é alterada. A consulta pública só estará concluída depois dessa acção ser executada e confirmada em `/documentos`. + +Na base activa, os recursos 1–3 têm apenas relações gerais `module=simulator` e/ou `theme=*`, todas com revisão `pending`; o recurso 4 não tem relação de módulo/requisito. Isso não bloqueia cadastro nem consulta. A migração de associações `requirement` não está aplicada na base activa: as chaves fiscais abaixo estão documentadas no código/QA, mas não persistidas como relações de requisito nesta base. O INE não tem associação fiscal apropriada. + +As cópias e derivados em `storage/app/private/geo-ine-review-*` incluem `review.csv`, JSON de revisão/proveniência e extracções de layout. Foram contados por nome, tamanho e hash, não examinados nem tratados como fontes públicas. Permanecem privados; não são duplicados documentais a publicar. + +## Fontes fiscais e correspondência com regras + +### Referências oficiais e auxiliares sem ficheiro local + +O registo de fontes fiscais contém **25 URLs únicos**. A reconciliação integral dessas referências encontra-se em [fiscal-source-references-2026-10-06.csv](fiscal-source-references-2026-10-06.csv), separada do inventário de ficheiros para não contar uma URL como documento importado. Cada linha conserva o rótulo e endereço exactos, indica que a referência não prova a existência de um binário local e especifica o destino: obter/conferir a fonte, sem activar regras. Algumas URLs apontam para fichas/catálogos e outras para PDFs; quando um PDF coincidir com um dos quatro ficheiros já inventariados, só deve ser contado uma vez pelo SHA-256, após comparação dos bytes. + +As 25 referências cobrem: catálogos da AT e INSS; cadeia do CIVA (Leis 3/2012, 13/2016, 16/2020, 22/2022, 3/2025 e 10/2025 e rectificação BR 89/2017); BR 248/2025; pauta aduaneira e e-Viajante; câmbio do Banco de Moçambique; anúncio governamental e fichas INM dos BR 186/187 de 2026; microfinanças (Decreto 50/2024, BM e BR 134/2024); e reprodução auxiliar da Lei 12/2025. A existência de URL oficial não significa que o texto integral foi obtido ou analisado: o estado individual descrito no registo oficial prevalece. A cópia de terceiro é explicitamente auxiliar, nunca evidência primária. + +Fontes primárias presentes no acervo activo: Lei 22/2022, BR 248/2025 e BR 89/2017. O código de importação/revisão identifica a Lei 22/2022, art. 17(1), e as Leis 7–12/2025 no BR 248; as relações activas à data desta leitura são somente tags gerais pendentes, não relações persistidas a requisitos `fiscal.*`. A associação candidata do BR 89/2017 também permanece pendente. A identificação de artigos é evidência documental, não validação da incidência, vigência integral ou fórmula. + +| Componente | Fonte localizada/ligada | Lacuna documental ou de regra | Estado de uso | +|---|---|---|---| +| IVA | Lei 22/2022 art. 17(1); Lei 10/2025 e Lei 3/2025 dentro do BR 248; cadeia de 2016/2017 descrita no registo oficial | O PDF da republicação integral Lei 13/2016 e cópia legível/autêntica dos anexos rectificados continuam inacessíveis; faltam os binários/efeitos consolidados das alterações 2020 e regulamentos publicados/anunciados em 2026 | Um ponto textual de 16% está identificado no documento; sem cálculo fiscal integrado | +| IRPC | Lei 12/2025, arts. e dispositivos indicados na revisão do BR 248 | Código-base consolidado, regulamentos 2026, retenções, mais-valias digitais/estabelecimento estável por contexto e casos dourados completos não estão arquivados neste lote | Sem regra activada | +| ISPC | Lei 9/2025 e republicação anexa da Lei 5/2009 no BR 248 | Diploma-base/regulamento integral legível e alterações regulamentares de 2026; elegibilidade e exclusões por operação ainda carecem de consolidação | Sem regra activada; não confundir com regimes simplificados de IVA/IRPS | +| IRPS | Lei 11/2025 no BR 248 | Código-base consolidado, regulamentos 2026, residência e categorias com alcance/obrigações por período | Sem regra activada; retenção mensal não representa apuramento anual | +| INSS | Referências externas ao RSSO Decreto 51/2017 e alterações, incluindo Decreto 56/2024, em registo de fontes | Nenhum diploma binário está no arquivo fiscal; taxas/base do regime geral e TCP, limites e vigência ainda não foram conferidos | Sem regra activada; taxas 4%/3% continuam sintéticas/não aprovadas | +| Retenções | Dispositivos diversos nas Leis 11/2025 e 12/2025 citadas no BR 248 | Falta matriz por natureza, residência, convenção, evento de retenção, documento e vigência; não há fundamento suficiente para uma regra universal | Sem regra activada | +| ICE | Lei 7/2025, incluindo tabela anexa, citada no BR 248 | Código completo, produto/unidade/base/taxa, transições e regulamentação recente não estão reconciliados em arquivo executável | Sem regra activada | +| Aduaneiro | Lei 8/2025 e tabela anexa citadas no BR 248 | Pauta de serviço 2026 por código, origem/preferência, classificação, unidade, câmbio aduaneiro e regras da base de IVA importação não foram arquivados e comparados campo a campo | Sem regra activada; sem código pautal não calcular DA | +| Benefícios/incentivos | Lei 4/2009 é citada por cópia consolidada antiga em inventário anterior, actualmente inacessível | Regime, certificado, actividade, área, período e elegibilidade não têm fontes completas no arquivo disponível | Não há concessão nem benefício automático | + +O registo `simulation_tax_rule_versions` não mostrou versões no contexto de leitura da base activa. Independentemente dessa consulta, `RunSimulation` não invoca `SimulationTaxParameterResolver` nem `SimulationTaxRuleExpression` para calcular IRPC, IVA, ISPC, IRPS, INSS, retenções ou aduaneiro; a aritmética genérica introduzida pelo utilizador continua separada. A governança DSL/DAG, o resolvedor temporal e os cenários de QA não são regras fiscais activas. Não alterei cálculos, parâmetros, snapshots ou resultados nesta revisão. + +Material secundário `Reforma fiscal de 2026.docx`: foi analisado em revisão anterior como resumo e reconciliado com partes do BR 248; não é tratado como diploma e o binário não está acessível agora. PDF da pauta AT 2026, regulamentos BR 186/187, cópias integrais dos códigos, anexos de pauta/ICE, diplomas INSS, convenções, regulamentos e fontes cambiais permanecem não arquivados/não analisados integralmente, conforme a pendência individual acima ou o registo de fontes oficiais. + +## Fluxo reutilizável de importação em lote + +Foi criado `documents:import-batch`, que consome um manifesto JSON e ficheiros dentro de `storage/app/private/document-import-staging`. Sem `--apply`, calcula hash, valida MIME/tamanho e pré-visualiza documentos novos, hashes já existentes, duplicados no próprio lote e conflitos de identidade/versão. Com `--apply --actor=`, exige autor real, copia para armazenamento privado, cria rascunho interno em quarentena, regista revisão e auditoria, e coloca a análise antimalware na fila. Não publica, não confirma direitos, autenticidade ou vigência e não exige associações para consulta. Associação a módulo/requisito é uma etapa posterior na gestão. Não há fallback para paths fora do staging; máximo de 10 MiB por ficheiro e 100 entradas por lote. + +Uso operacional: + +```powershell +New-Item -ItemType Directory -Force storage/app/private/document-import-staging/lote-20261006 +# Copiar os ficheiros de origem para esta pasta e criar manifest.json conforme o exemplo abaixo. +php artisan documents:import-batch lote-20261006/manifest.json +php artisan documents:import-batch lote-20261006/manifest.json --apply --actor= +``` + +Cada item exige `external_id`, `relative_path`, `title` e `issuer`; recomenda-se indicar também nome, tipo, edição, número, data, fonte HTTPS, referência e idioma. O formato está exemplificado em [document-import-manifest.example.json](document-import-manifest.example.json). Pré-visualizar e resolver conflitos antes de aplicar. Depois de importar: aguardar scan; completar metadados e proveniência; definir direitos/classificação segundo a autorização aplicável; associar a módulos; publicar explicitamente apenas após as verificações. Repetições pelo mesmo hash não criam documentos ou revisões duplicados. + +## Verificações executadas e pendências + +- Base activa conferida por leitura: 4 documentos/recursos; 4 `active`/`clean`; 4 hashes analisados iguais aos armazenados; 0 publicados; direitos `unconfirmed`; classificação `internal`. +- Lote fiscal existente: `php artisan documents:import-legal-source-archive` em pré-visualização; quatro itens `reused/reconciled`, todos `published=no`; nenhuma escrita. +- Nova importação genérica testada apenas na QA isolada definida por `phpunit.xml`: host `127.0.0.1`, porta `55441`, base `investmoz_test`, sem `DB_URL`; porta confirmada aberta. `php artisan test tests/Feature/ImportDocumentBatchTest.php`: **2 testes, 23 asserções aprovados**, cobrindo pré-visualização sem escrita, rascunho privado, autoria/auditoria, scan enfileirado, repetição idempotente, actor ausente e path traversal. +- `php -l` no comando e teste: aprovado. `git diff --check`: sem erros de whitespace; Git reportou apenas avisos de conversão CRLF em ficheiros já modificados. +- Nenhuma migration foi aplicada. Nenhum registo ou estado de publicação activo foi alterado pela pré-visualização. Não foi executado `--apply` no lote real desta revisão, pois os quatro documentos já existem. +- Pendência para disponibilização pública dos recursos 1–4: entrar como gestor elegível em `/management/portal-services` e executar o botão indicado; esta é a operação autenticada existente, não uma nova autorização. Depois confirmar pesquisa, ficha e download em `/documentos`. +- Pendências do corpus: localizar/fornecer individualmente os dez itens inacessíveis listados no CSV; verificar vigência, completude e associação artigo por artigo antes de qualquer regra. O relatório não considera inventário de prompts como acervo jurídico. + +## Rollback + +Não houve migration nem mutação na base activa nesta etapa. O novo comando só grava com `--apply`; os documentos ficam privados e em quarentena técnica. Para reverter a alteração de código, retirar `app/Console/Commands/ImportDocumentBatch.php`, o respectivo teste e esta regra de projecto, preservando quaisquer registos ou ficheiros criados por lotes reais até reconciliação. Não apagar staging/originais nem executar `migrate:rollback` como atalho. diff --git a/docs/investmoz/document-import-manifest.example.json b/docs/investmoz/document-import-manifest.example.json new file mode 100644 index 00000000..bd364f68 --- /dev/null +++ b/docs/investmoz/document-import-manifest.example.json @@ -0,0 +1,21 @@ +{ + "documents": [ + { + "external_id": "issuer-document-number-edition-v1", + "relative_path": "lote-aaaammdd/original.pdf", + "file_name": "original.pdf", + "title": "Título exacto conforme à capa/ficha do documento", + "issuer": "Entidade emitente identificada", + "document_number": "Número do diploma ou referência, se existir", + "document_date": "2026-01-01", + "language": "pt", + "version_label": "Edição/data identificada", + "category": "legislation", + "topic": "tema", + "document_type": "legislation", + "source_url": "https://example.org/source", + "source_reference": "Origem identificada; autenticidade e vigência independentes por rever.", + "description": "Descrição factual sem afirmar aplicabilidade ou elegibilidade." + } + ] +} diff --git a/docs/investmoz/document-requirement-associations-2026-10-06.md b/docs/investmoz/document-requirement-associations-2026-10-06.md new file mode 100644 index 00000000..508325a2 --- /dev/null +++ b/docs/investmoz/document-requirement-associations-2026-10-06.md @@ -0,0 +1,66 @@ +# Associações documentais a requisitos — 06/10/2026 + +## Âmbito e resultado + +Foi concluído o formulário administrativo e a relação entre documentos e identificadores técnicos de requisito. Estas relações são exclusivamente evidência documental: não criam obrigações legais, não aprovam interpretação ou vigência e não activam nem alteram qualquer fórmula, parâmetro ou cálculo fiscal. + +Não existia um catálogo de requisitos documentais fiscais no esquema actual. O catálogo técnico criado em `config/document-requirements.php` usa chaves estáveis `fiscal.*`; cada chave identifica um assunto do simulador e o diploma associado, não uma regra legal executável. A interface administrativa selecciona uma chave da lista; o servidor rejeita chaves fora do catálogo. Cada ligação tem estado `pending` ou `confirmed`. `confirmed` significa que a citação foi localizada no ficheiro indicado; a aplicação jurídica continua explicitamente pendente. + +A migração aditiva `2026_10_06_200000_add_document_requirement_evidence_links` acrescenta o tipo `requirement`, o estado de conferência e o tipo de revisão `evidence_linked`. Registos anteriores recebem `pending`; nenhum foi promovido em massa. A reversão recusa remover relações ou revisões deste tipo enquanto existirem. O importador reconcilia ligações em falta em recursos já importados, sem duplicar ficheiros, e cria uma revisão do catálogo. + +## Referências do BR n.º 248/2025, Suplemento n.º 1 + +O PDF de 21 páginas foi descarregado do catálogo/WebDAV oficial da Autoridade Tributária e comparado com a cópia arquivada: 440.018 bytes e SHA-256 `CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251`. A ficha da Imprensa Nacional identifica a edição. A leitura directa foi feita no texto extraído do próprio PDF, com localização por página. O número do boletim, isoladamente, não é usado como referência de requisito. + +| Chave técnica | Diploma identificado dentro do BR | Dispositivos citados no texto primário | Páginas PDF | Estado da associação | +|---|---|---|---:|---| +| `fiscal.ice.lei_7_2025` | Lei n.º 7/2025, de 29 de Dezembro, alteração ao Código do ICE | Lei, arts. 1–5 e tabela anexa; art. 1 prorroga a aplicação das taxas até 2027 e art. 5 indica entrada em vigor em 01/01/2026 | 1–2 | Citação localizada; vigência consolidada, regulamentação, alcance da tabela e aprovação fiscal pendentes | +| `fiscal.customs.lei_8_2025` | Lei n.º 8/2025, de 29 de Dezembro, alteração à Pauta Aduaneira e Instruções Preliminares | Lei, art. 1 altera os arts. 21(2), 22 e 27 das Instruções Preliminares; Lei, arts. 2–5 e tabela anexa | 2–7 | Citação localizada; classificação por código/origem, preferências, regulamentação e aplicação a operações concretas pendentes | +| `fiscal.ispc.lei_9_2025` | Lei n.º 9/2025, de 29 de Dezembro, alteração ao ISPC | Lei, arts. 1–7 e republicação anexa da Lei n.º 5/2009 | 8–11 | Citação localizada; interpretação, regulamento, elegibilidade e aprovação fiscal pendentes | +| `fiscal.iva.lei_10_2025` | Lei n.º 10/2025, de 29 de Dezembro, alteração ao CIVA | Lei, arts. 1–4; no texto alterado encontram-se, entre outros, CIVA arts. 18, 19, 21, 23, 25-A, 27 e 32–34 | 12–15 | Citação localizada; consolidação, regulamentação, vigência por dispositivo e aprovação fiscal pendentes | +| `fiscal.irps.lei_11_2025` | Lei n.º 11/2025, de 29 de Dezembro, alteração ao CIRPS | Lei, arts. 1–4; o texto alterado inclui CIRPS arts. 21, 22, 48, 50, 54-A, 57, 65 e 65-A | 16–18 | Citação localizada; alcance, transições, regulamentos e aprovação fiscal pendentes | +| `fiscal.irpc.lei_12_2025` | Lei n.º 12/2025, de 29 de Dezembro, alteração ao CIRPC | Lei, arts. 1–4; o texto alterado inclui CIRPC arts. 3, 5, 20, 62, 67 e 75; Lei, art. 2 adita o CIRPC art. 61-A | 19–20 | Citação localizada; alcance, transições, regulamentos e aprovação fiscal pendentes | + +As referências de página/artigo acima foram conferidas no próprio PDF. “Citação localizada” não afirma que todos os dispositivos citados incidam sobre qualquer utilizador, que toda a lei continue vigente sem alterações, nem que exista uma obrigação executável. As seis ligações aparecem no sistema como `applicability=evidence`. + +Também existe a chave `fiscal.iva.lei_22_2022` para a Lei n.º 22/2022, art. 17(1), página 3 do PDF republicado. A citação do texto com a taxa de 16% foi localizada, mas a associação documental não altera o motor fiscal. A relação à rectificação de 2017 usa a chave `fiscal.iva.lei_13_2016_rectification_2017` e permanece `pending`. + +## Quatro ficheiros: origem e pendências por tipo + +| Ficheiro e título | Diploma/conteúdo e origem | Autenticidade | Vigência/actualidade | Possibilidade de divulgação | +|---|---|---|---|---| +| `Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf` — Lei n.º 22/2022, alteração ao CIVA | [INM — 2.º Suplemento do BR n.º 250](https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da); cópia também comparada byte a byte com o endpoint oficial AT | Confirmada ao nível da origem e bytes (1.834.284 bytes; hash no registo de migração) | Documento alterador de 2022, marcado `amended`; não é consolidação integral actual. Art. 17(1) conferido, mas período e alterações posteriores devem acompanhar a consulta | A lei é texto oficial. Falta decisão administrativa documentada sobre divulgação desta composição/fac-símile PDF e confirmação de direitos de reutilização do artefacto. O registo continua `internal`, direitos `unconfirmed` | +| `BR-248-I-Suplemento-1-2025.pdf` — BR n.º 248/2025, Suplemento n.º 1 | [INM — ficha da edição](https://inm.gov.mz/pt-br/content/suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie); PDF comparado byte a byte com WebDAV oficial AT | Confirmada ao nível da edição, origem oficial e bytes (440.018 bytes) | Edição contém seis diplomas distintos (Leis 7–12/2025). Não há um único estado de vigência para o boletim; revisão deve ser por lei/artigo e regulamento | Falta documentar a autorização aplicável à divulgação do ficheiro composto, incluindo a apresentação integral do boletim. Permanece privado com direitos `unconfirmed` | +| `BR-89-I-Serie-2017-06-08.pdf` — BR n.º 89/2017, aviso de rectificação da Lei n.º 13/2016 | [INM — ficha do BR 89, p. 569](https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569) | A ficha oficial confirma a existência do aviso; o PDF local não foi comparado com exemplar oficial descarregado e a cadeia de custódia não está demonstrada | O aviso altera o CIVA e manda rectificar anexos I–III. Os anexos substitutivos e o efeito temporal ainda não foram cotejados com cópia autêntica; não serve como consolidação | Falta autenticar o binário e esclarecer a divulgação do fac-símile. Mantido privado; associação candidata pendente, não exibida como fundamento confirmado | +| `INE-Codificador-Divisao-Politico-Administrativa-2012.pdf` — Codificador DPA, edição de 07/06/2012 | [INE — PDF oficial](https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d) | Cópia local comparada byte a byte com ficheiro servido pelo INE | Edição de 2012; actualidade de códigos/designações territoriais precisa de confirmação antes de orientar cadastro actual | Falta documentar direitos de reutilização da composição PDF. Mantido privado. Não associado a requisito fiscal | + +Nenhum destes quatro ficheiros foi publicado nesta execução. O estado antimalware limpo não é autorização de divulgação. As pendências são por ficheiro e independem umas das outras. + +## Revisão e publicação autenticadas + +O teste de browser no portal activo confirmou que `http://localhost:8000/documentos` responde 200 e apresenta o estado vazio, sem erros JavaScript; a base activa contém 4 recursos e 0 publicados. A migração nova não foi aplicada na base activa. `/management/portal-services` redirecciona para `/login`, pelo que a revisão autenticada não foi executada nem contornada. + +Roteiro para o gestor: + +1. Aceder a `http://localhost:8000/login` e entrar normalmente na conta de gestor, completando o MFA configurado. +2. Abrir `http://localhost:8000/management/portal-services` e localizar cada rascunho pelo título/ficheiro. +3. Conferir fonte, citação e página. No BR 248, rever cada Lei 7/2025 a 12/2025 individualmente; não usar “BR 248” como referência suficiente. +4. Em cada ficheiro, decidir separadamente autenticidade, estado temporal e autorização de divulgação. Não preencher direitos como autorizados sem base documentada. Para o BR 89, obter primeiro exemplar autêntico e anexos rectificados. +5. Guardar associações `requirement` como evidência. “Citação conferida” refere-se apenas ao texto e localização lidos; conservar a nota de que interpretação e aplicação fiscal estão pendentes. +6. Publicar individualmente apenas depois de os controlos do próprio formulário aceitarem fonte, direitos, classificação pública e scan limpo. A publicação é uma acção própria e auditada; não publicar os quatro por lote. +7. Após aplicação controlada da migração e publicação, abrir `/documentos`, pesquisar pelo título/número, consultar a ficha e conferir a secção de requisitos documentais relacionados; estes identificadores são relações documentais, não páginas ou obrigações legais autónomas. No simulador, consultar a evidência confirmada apresentada junto da recomendação documental. Descarregar o ficheiro pela ligação protegida. Só então registar evidência positiva no portal activo. + +## Validação executada + +- Alvo de QA do PHPUnit: PostgreSQL em loopback, porta 55441, base `investmoz_test`; o guard em `tests/TestCase.php` recusa a base activa e não permite fallback por `DB_URL`. +- `php artisan test tests/Feature/PortalServicesTest.php tests/Feature/DocumentLibrarySchemaCompatibilityTest.php tests/Feature/ImportLegalSourceArchiveTest.php`: **18 testes, 433 afirmações aprovadas**. Cobre validação da chave técnica, estados conferidos/pendentes, importação privada, zero publicação, reconciliação após faltar uma associação e ausência de duplicação na repetição. +- `npm.cmd run types:check`: aprovado. +- `npm.cmd run build`: aprovado. Avisos não bloqueantes: chunk `services` acima de 500 kB e dependência opcional `fontaine` não instalada. +- `php -l` nos ficheiros PHP alterados: aprovado. +- Portal activo: Playwright em desktop, `/documentos` HTTP 200, título “Documentos e Formulários”, estado vazio visível, sem erros JS. Gestão autenticada redireccionou a `/login`. Não houve associação, publicação ou alteração de dados da base activa. + +## Rollback e pendências + +Em QA, a migração é reversível apenas antes de existirem relações `requirement` e revisões `evidence_linked`; a migração recusa rollback se encontrar esses dados. Na base activa, nenhuma migração foi aplicada e não há rollback de dados a executar. Para activar a associação no portal activo, rever a migração e preparar/restaurar um backup conforme o procedimento operacional existente; depois correr a migração nominal acima e confirmar `php artisan portal:check-document-library-schema`. Não correr migrações globais. + +Pendências: aplicação controlada da migração no ambiente activo; revisão autenticada pelo gestor; decisão documental individual sobre os direitos de divulgação dos quatro artefactos; autenticação e anexos rectificados do BR 89; confirmação da actualidade territorial do codificador INE; e evidência posterior de pesquisa, requisito e download positivos. Nenhuma destas pendências activa cálculos fiscais. diff --git a/docs/investmoz/fiscal-source-references-2026-10-06.csv b/docs/investmoz/fiscal-source-references-2026-10-06.csv new file mode 100644 index 00000000..683725bd --- /dev/null +++ b/docs/investmoz/fiscal-source-references-2026-10-06.csv @@ -0,0 +1,26 @@ +"SourceRegister";"Label";"URL";"LocalBinary";"State";"Disposition" +"docs/investmoz/simulations/official-source-register.md";"Autoridade Tributária — Legislação Fiscal";"https://www.at.gov.mz/legislacao/fiscal";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Lei n.º 3/2012 — catálogo INM";"https://inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-1-de-230112-pag-34-1-6-br-n%C2%BA-3-boletim-da-rep%C3%BAblica-i";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Lei n.º 13/2016 — republicação integral INM";"https://www.inm.gov.mz/pt-br/content/suplemento-n%C2%BA-10-de-301216-pag-1426-259-290-br-n%C2%BA-156-boletim-da-rep%C3%BAblica-i-serie";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"rectificação — BR 89/2017 INM";"https://inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Lei n.º 16/2020 — INM";"https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-1-de-231220-pag-2414-1-20-br-n%C2%BA-246-boletim-da";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Lei n.º 22/2022 republicada — INM";"https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Lei n.º 3/2025 — INM";"https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-1-de-290525-pag-478-1-2-br-n%C2%BA-95-boletim-da-rep%C3%BAblica";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Lei n.º 10/2025 — Parlamento";"https://www.parlamento.mz/wp-content/uploads/2025/05/iva.pdf";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"INSS — Legislação";"https://www.inss.gov.mz/legislacao/";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"direitos e obrigações do contribuinte";"https://www.inss.gov.mz/direitos-e-obrigacoes-do-contribuinte/";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"AT — Pauta Aduaneira de Serviço 2026";"https://www.at.gov.mz/servicos/pauta-aduaneira";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"bundle público da SPA AT";"https://www.at.gov.mz/assets/index-XEcizcP2.js";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Portal e-Viajante / AT";"https://e-viajante.at.gov.mz/";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Banco de Moçambique — taxas de referência";"https://www.bancomoc.mz/pt/areas-wrapper/taxas-de-cambio-de-referencia-intradiaria/";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"INM — Suplemento n.º 1 do BR 248, 29-12-2025";"https://inm.gov.mz/pt-br/content/sum%C3%A1rio-suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Portal do Governo — comunicado de 30-09-2026";"https://portaldogoverno.gov.mz/governo-introduz-alteracoes-nos-regulamentos-fiscais-para-reforcar-eficiencia-tributaria/";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"catálogo oficial da INM — Boletins da República";"https://inm.gov.mz/pt-br/bulletin?field_tipo_de_produto_tid_br=43";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"ficha INM BR 186";"https://inm.gov.mz/pt-br/content/br-n%C2%BA-186-de-300926-boletim-da-rep%C3%BAblica-i-serie";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"ficha INM BR 187";"https://inm.gov.mz/pt-br/content/br-n%C2%BA-187-de-011026-boletim-da-rep%C3%BAblica-i-serie";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"BM — licenciamento e legislação do sistema";"https://bancomoc.mz/pt/areas-de-actuacao/licenciamento/licenciamento-de-instituicoes/";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"Decreto 50/2024 em PDF oficial";"https://www.bancomoc.mz/media/jcdnvi0r/decreto-50-2024-11-de-julho-regulamento-da-lei-das-institui%C3%A7%C3%B5es-de-cr%C3%A9dito-e-sociedades-financeiras.pdf";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"comunicado cadastral BM de 11-09-2026";"https://bancomoc.mz/media/j2fnsbjp/drl_cadastro-operadores-pt.pdf";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"sumário oficial INM do BR 134/2024";"https://www.inm.gov.mz/pt-br/content/sum%C3%A1rio-br-n%C2%BA-134-de-110724-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-2827";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"ficha oficial INM do aviso";"https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" +"docs/investmoz/simulations/official-source-register.md";"reprodução integral de terceiro, auxiliar";"https://www.lexlink.eu/FileGet.aspx?FileId=2999523";"Não identificado por esta referência; URL registada";"Referência de pesquisa/documentação; não conta como ficheiro importado";"Manter como fonte externa a obter/conferir; não activa regra fiscal" diff --git a/docs/investmoz/legal-source-library-migration-2026-10-06.md b/docs/investmoz/legal-source-library-migration-2026-10-06.md index 1d21da18..822f2336 100644 --- a/docs/investmoz/legal-source-library-migration-2026-10-06.md +++ b/docs/investmoz/legal-source-library-migration-2026-10-06.md @@ -2,6 +2,8 @@ Data: 06/10/2026. Este relatório substitui, para o estado actual, a observação de biblioteca vazia feita em 05/10. A migração foi aplicada à base local `investemoz` depois de um backup novo; não houve alteração aos PDFs originais. +**Adenda de associações documentais (06/10/2026):** o estado posterior do formulário, chaves técnicas de requisito, conferência de artigos do BR 248, revisão individual dos quatro ficheiros, testes QA e verificação actual do portal estão em [document-requirement-associations-2026-10-06.md](document-requirement-associations-2026-10-06.md). A adenda confirma 4 recursos na base activa, 0 publicados e que a nova migração de relações ainda não foi aplicada nessa base. Os valores de 3 ficheiros/11 ligações abaixo descrevem a etapa anterior desta migração, não o estado actual. + ## Resultado actual Foram copiados três PDFs do arquivo existente para o armazenamento privado do portal e registados em `documents` e `portal_resources`. A importação é transaccional e repetível; a segunda execução reutilizou os três registos pelos hashes, sem criar documentos, recursos, ligações ou revisões duplicados. diff --git a/resources/js/pages/investment/business-marketplace.tsx b/resources/js/pages/investment/business-marketplace.tsx index 710fea0a..4ed57786 100644 --- a/resources/js/pages/investment/business-marketplace.tsx +++ b/resources/js/pages/investment/business-marketplace.tsx @@ -2,29 +2,38 @@ import { Link, router } from "@inertiajs/react"; import { useState } from "react"; import { Frame, button, card, input } from "./shared"; import { TerritoryFields, type TerritoryOption } from "@/components/investment-territory"; +import { businessModalities } from "./business-types"; type PublicBusiness = { - id: string; slug: string; title: string; objective?: string | null; stage?: string | null; + id: string; slug: string; title: string; modality: string; objective?: string | null; stage?: string | null; amount_min?: string | null; amount_max?: string | null; currency?: string | null; sector?: { name: string; slug: string } | null; province?: string | null; }; type PaginationLink = { url: string | null; label: string; active: boolean }; -type Props = { results: { data: PublicBusiness[]; links?: PaginationLink[] | { first: string | null; last: string | null; prev: string | null; next: string | null }; meta?: { links?: PaginationLink[]; total?: number } }; filters: { q?: string; province_id?: string; district_id?: string; admin_post_id?: string; locality_id?: string }; territorySelection?: TerritoryOption[] }; +type Props = { results: { data: PublicBusiness[]; links?: PaginationLink[] | { first: string | null; last: string | null; prev: string | null; next: string | null }; meta?: { links?: PaginationLink[]; total?: number } }; filters: { q?: string; modality?: string; province_id?: string; district_id?: string; admin_post_id?: string; locality_id?: string }; territorySelection?: TerritoryOption[] }; export default function BusinessMarketplace({ results, filters, territorySelection = [] }: Props) { const [query, setQuery] = useState(filters.q ?? ""); + const [modality, setModality] = useState(filters.modality ?? ""); const [territory, setTerritory] = useState>({ province_id: filters.province_id ?? "", district_id: filters.district_id ?? "", admin_post_id: filters.admin_post_id ?? "", locality_id: filters.locality_id ?? "" }); - const returnQuery = new URLSearchParams({ ...(query ? { q: query } : {}), ...territory }).toString(); + const returnQuery = new URLSearchParams({ ...(query ? { q: query } : {}), ...(modality ? { modality } : {}), ...territory }).toString(); const paginationLinks = results.meta?.links ?? (Array.isArray(results.links) ? results.links : []); return (

Explore oportunidades aprovadas para publicação. A informação está sujeita a diligência independente.

{ event.preventDefault(); - router.get("/negocios", { q: query || undefined, ...territory }, { preserveState: true, preserveScroll: true }); + router.get("/negocios", { q: query || undefined, modality: modality || undefined, ...territory }, { preserveState: true, preserveScroll: true }); }}> setQuery(event.target.value)} /> + setTerritory({ province_id: path[0]?.id ?? "", district_id: path[1]?.id ?? "", admin_post_id: path[2]?.id ?? "", locality_id: path[3]?.id ?? "" })} /> @@ -33,6 +42,7 @@ export default function BusinessMarketplace({ results, filters, territorySelecti
    {results.data.map((record) => (
  • +

    {businessModalities[record.modality] ?? record.modality}

    {record.title}

    {[record.sector?.name, record.province, record.stage].filter(Boolean).join(" · ")}

    {(record.amount_min || record.amount_max) &&

    {record.amount_min ?? "—"} – {record.amount_max ?? "—"} {record.currency}

    } diff --git a/resources/js/pages/investment/business-opportunity.tsx b/resources/js/pages/investment/business-opportunity.tsx index c071b9cd..0e001fe2 100644 --- a/resources/js/pages/investment/business-opportunity.tsx +++ b/resources/js/pages/investment/business-opportunity.tsx @@ -1,8 +1,9 @@ import { Link } from "@inertiajs/react"; import { Frame, button, card } from "./shared"; +import { businessModalities } from "./business-types"; type Business = { - slug: string; title: string; objective?: string | null; stage?: string | null; activity_code?: string | null; + slug: string; title: string; modality: string; objective?: string | null; stage?: string | null; activity_code?: string | null; amount_min?: string | null; amount_max?: string | null; available_percentage?: string | null; currency?: string | null; province?: string | null; district?: string | null; locality?: string | null; sector?: { name: string; slug: string } | null; }; @@ -12,6 +13,7 @@ export default function BusinessOpportunity({ record, returnUrl = "/negocios", i Voltar às oportunidades
    +

    {businessModalities[record.modality] ?? record.modality}

    {[record.sector?.name, record.stage, record.activity_code].filter(Boolean).join(" · ")}

    {(record.province || record.district || record.locality) &&

    {[record.locality, record.district, record.province].filter(Boolean).join(", ")}

    } {(record.amount_min || record.amount_max) &&

    Montante indicativo: {record.amount_min ?? "—"} – {record.amount_max ?? "—"} {record.currency}

    } diff --git a/resources/js/pages/investment/business-types.ts b/resources/js/pages/investment/business-types.ts index 3c3ec812..a70fad54 100644 --- a/resources/js/pages/investment/business-types.ts +++ b/resources/js/pages/investment/business-types.ts @@ -51,6 +51,7 @@ export const businessStates: Record = { }; export const businessModalities: Record = { company_sale: 'Venda da empresa', + company_purchase: 'Compra ou aquisição de empresa', participation_sale: 'Venda de participação', capital_increase: 'Aumento de capital', financial_investor: 'Investidor financeiro', diff --git a/resources/js/pages/investment/catalogue.tsx b/resources/js/pages/investment/catalogue.tsx index a9ed52ce..6e8039ef 100644 --- a/resources/js/pages/investment/catalogue.tsx +++ b/resources/js/pages/investment/catalogue.tsx @@ -206,7 +206,11 @@ export default function Catalogue({ Fichas de ZFI Parques industriais Projectos âncora documentados - Negócios e participações publicados + Comprar uma empresa + Vender uma empresa + Imobiliário + Terrenos e direitos fundiários + Todos os negócios publicados

    Combine contexto territorial, dimensão e necessidades. As fichas documentais e as @@ -308,6 +312,18 @@ export default function Catalogue({ + {(form.data.zee_id || form.data.zfi_id || form.data.park_id) && (() => { + const selectedId = form.data.zee_id || form.data.zfi_id || form.data.park_id; + const selectedZone = references.zones?.find((zone) => zone.id === selectedId); + return selectedZone ? ( +

    + Enquadramento seleccionado: {selectedZone.name}.{' '} + + Abrir ficha e oportunidades associadas + +

    + ) : null; + })()}
    @@ -891,6 +901,20 @@ export default function Manage({ + {disclosureAuthorization && ( +
    +

    Autorização expressa de disponibilização documental

    +

    Registada em {new Date(disclosureAuthorization.occurred_at).toLocaleString()}; origem: instrução do utilizador, sem atribuição a gestor. Abrange consulta e download dos documentos listados e não certifica autenticidade ou vigência, nem activa cálculos fiscais.

    +
      + {disclosureAuthorization.resources.map((resource) =>
    • #{resource.id} · {resource.title} · {resource.published ? 'já publicado' : `rascunho (${resource.rights_status})`}
    • )} +
    +

    A acção mantém a autenticação de gestão e só publica ficheiros activos cujo hash corresponda ao resultado antimalware e que tenham fonte identificada. As situações de fonte e vigência são preservadas e apresentadas ao público. Registos que falhem estes controlos ficam pendentes individualmente.

    + +
    + )} +

    Registos, pendências e duplicados por rever

    diff --git a/resources/js/pages/portal/services.tsx b/resources/js/pages/portal/services.tsx index 95c9db40..b2c3a5fa 100644 --- a/resources/js/pages/portal/services.tsx +++ b/resources/js/pages/portal/services.tsx @@ -58,6 +58,7 @@ type Resource = { links: { target_type: string; target_key: string; + target_label?: string | null; relationship: string; applicability: string; condition_text: string | null; @@ -1081,6 +1082,11 @@ export default function Services(p: Props) { : 'Fonte por confirmar'} + {r.source_confirmation === 'identified' && ( +
    + A origem está identificada, mas a autenticidade desta cópia não foi confirmada independentemente. Consulte a fonte indicada. +
    + )}
    Vigência @@ -1139,7 +1145,7 @@ export default function Services(p: Props) { ) : ( <> - {link.target_type}: {link.target_key} + {link.target_type === 'requirement' ? `Requisito documental: ${link.target_label ?? link.target_key}` : `${link.target_type}: ${link.target_key}`} )}{' '} · {link.relationship} ·{' '} diff --git a/routes/web.php b/routes/web.php index 0960d50d..35a3b189 100644 --- a/routes/web.php +++ b/routes/web.php @@ -48,6 +48,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::middleware(['auth', 'verified', 'can:manage-portal'])->group(function () { Route::get('/management/portal-services', [PortalServicesController::class, 'manage'])->name('services.manage'); + Route::post('/management/portal-services/disclosure-authorization/apply', [PortalServicesController::class, 'applyDocumentDisclosureAuthorization'])->name('services.disclosure-authorization.apply'); Route::get('/management/documents/{id}/download', [PortalServicesController::class, 'downloadForManager'])->whereUuid('id')->name('documents.manage.download'); Route::post('/management/portal-services/upload', [PortalServicesController::class, 'upload'])->middleware('throttle:10,1')->name('services.upload'); Route::post('/management/documents/{id}/malware/retry', [DocumentMalwareController::class, 'retry'])->whereUuid('id')->middleware('throttle:10,1')->name('documents.malware.retry'); diff --git a/tests/Feature/BusinessMarketplacePublicTest.php b/tests/Feature/BusinessMarketplacePublicTest.php index 849cf766..5f692278 100644 --- a/tests/Feature/BusinessMarketplacePublicTest.php +++ b/tests/Feature/BusinessMarketplacePublicTest.php @@ -67,8 +67,53 @@ class BusinessMarketplacePublicTest extends TestCase $this->assertDatabaseCount('interest_requests', 1); } + public function test_public_catalogue_filters_by_existing_opportunity_modality_and_opens_response_flow(): void + { + $business = $this->publishedBusiness('real_estate'); + $companySale = $this->publishedBusiness('company_sale'); + $landRight = $this->publishedBusiness('land_right'); + + $this->getJson('/api/v1/business-opportunities?modality=real_estate')->assertOk() + ->assertJsonCount(1, 'data')->assertJsonPath('data.0.modality', 'real_estate'); + $this->getJson('/api/v1/business-opportunities?modality=company_sale')->assertOk() + ->assertJsonCount(1, 'data')->assertJsonPath('data.0.modality', 'company_sale'); + $this->getJson('/api/v1/business-opportunities?modality=land_right')->assertOk() + ->assertJsonCount(1, 'data')->assertJsonPath('data.0.modality', 'land_right'); + + $this->get('/negocios?modality=real_estate')->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-marketplace') + ->where('filters.modality', 'real_estate')->has('results.data', 1)); + $this->get('/negocios/'.$business['public_slug'])->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-opportunity') + ->where('record.modality', 'real_estate')); + $this->get('/negocios/'.$business['public_slug'].'/manifestar-interesse')->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-interest') + ->where('record.modality', 'real_estate')); + $this->get('/negocios?modality=company_sale')->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-marketplace') + ->where('filters.modality', 'company_sale')->has('results.data', 1)); + $this->get('/negocios/'.$companySale['public_slug'])->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-opportunity') + ->where('record.modality', 'company_sale')); + $this->get('/negocios?modality=land_right')->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-marketplace') + ->where('filters.modality', 'land_right')->has('results.data', 1)); + $this->get('/negocios/'.$landRight['public_slug'])->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business-opportunity') + ->where('record.modality', 'land_right')); + + $this->postJson('/api/v1/interest-requests', [ + 'existingBusinessId' => $business['id'], 'modality' => 'real_estate', + 'fullName' => 'Synthetic investor', 'email' => 'portal-integration-test@gmail.com', 'phone' => '+258 840000000', + 'investorType' => 'empresa', 'country' => 'MZ', 'sectorInterest' => 'Imobiliário', + 'message' => 'Synthetic interest in the published real estate opportunity.', + 'privacyConsent' => true, + ])->assertCreated(); + $this->assertDatabaseHas('interest_requests', ['existing_business_id' => $business['id'], 'modality' => 'real_estate', 'status' => 'received']); + } + /** @return array{id:string,public_slug:string} */ - private function publishedBusiness(): array + private function publishedBusiness(string $modality = 'company_sale'): array { $id = (string) Str::uuid(); $slug = 'public-synthetic-'.$id; @@ -77,7 +122,7 @@ class BusinessMarketplacePublicTest extends TestCase 'id' => $id, 'code' => 'SYN-'.strtoupper(substr(str_replace('-', '', $id), 0, 12)), 'owner_user_id' => User::factory()->create()->id, - 'modality' => 'company_sale', + 'modality' => $modality, 'status' => 'published', 'classification' => 'confidential', 'version_no' => 1, diff --git a/tests/Feature/ExistingBusinessIntakeTest.php b/tests/Feature/ExistingBusinessIntakeTest.php index 5a61e258..831d07bb 100644 --- a/tests/Feature/ExistingBusinessIntakeTest.php +++ b/tests/Feature/ExistingBusinessIntakeTest.php @@ -195,4 +195,43 @@ class ExistingBusinessIntakeTest extends TestCase $this->assertStringContainsString('Synthetic holder', $snapshot); $this->actingAs(User::factory()->create())->getJson($this->base.'/'.$first)->assertForbidden(); } + + public function test_company_sale_real_estate_and_land_right_have_distinct_private_subject_records(): void + { + $owner = User::factory()->create(); + $this->actingAs($owner); + $cases = [ + ['modality' => 'company_sale', 'kind' => 'company', 'label' => 'Synthetic company sale'], + ['modality' => 'real_estate', 'kind' => 'property', 'label' => 'Synthetic commercial property'], + ['modality' => 'land_right', 'kind' => 'land_right', 'label' => 'Synthetic declared land right'], + ]; + + foreach ($cases as $case) { + $businessId = $this->postJson($this->base, [ + 'modality' => $case['modality'], + 'classification' => 'confidential', + 'reason' => 'Synthetic modality-specific QA draft', + 'payload' => ['title' => $case['label'], 'wizard_version' => '2', 'wizard_step' => '6'], + ])->assertCreated()->json('id'); + + $this->get('/investidor/negocios/'.$businessId)->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/business') + ->where('record.modality', $case['modality']) + ->has('formSchema') + ->where('objectKinds', fn ($kinds) => $kinds->contains($case['kind']))); + + $objectId = $this->postJson($this->base.'/'.$businessId.'/objects', [ + 'kind' => $case['kind'], + 'version_no' => 1, + 'reason' => 'Synthetic subject record for QA', + 'payload' => ['label' => $case['label'], 'source_reference' => 'Synthetic source reference'], + ])->assertCreated()->assertJsonPath('verification_status', 'pending')->json('id'); + + $this->getJson($this->base.'/'.$businessId)->assertOk() + ->assertJsonPath('record.modality', $case['modality']) + ->assertJsonPath('objects.0.id', $objectId) + ->assertJsonPath('objects.0.kind', $case['kind']) + ->assertJsonPath('objects.0.verification_status', 'pending'); + } + } } diff --git a/tests/Feature/ExistingBusinessWizardTest.php b/tests/Feature/ExistingBusinessWizardTest.php index 770275c4..ead543c4 100644 --- a/tests/Feature/ExistingBusinessWizardTest.php +++ b/tests/Feature/ExistingBusinessWizardTest.php @@ -82,6 +82,19 @@ class ExistingBusinessWizardTest extends TestCase $this->assertStringNotContainsString('synthetic@example.test', $raw); } + public function test_company_purchase_is_a_distinct_existing_business_modality(): void + { + config(['existing-business.enabled' => true]); + $owner = User::factory()->create(); + $this->actingAs($owner)->postJson('/api/v1/existing-business/listings', [ + 'modality' => 'company_purchase', 'classification' => 'confidential', 'reason' => 'Prepare a company acquisition listing', + 'payload' => ['title' => 'Private company acquisition search', 'wizard_version' => '1', 'wizard_step' => '1'], + ])->assertCreated()->assertJsonPath('modality', 'company_purchase'); + $this->assertDatabaseHas('existing_businesses', ['owner_user_id' => $owner->id, 'modality' => 'company_purchase', 'status' => 'draft']); + $this->get('/negocios?modality=company_purchase')->assertOk() + ->assertInertia(fn ($page) => $page->where('filters.modality', 'company_purchase')->has('results.data', 0)); + } + public function test_multiple_locations_require_distinct_public_paths_from_the_same_hierarchy(): void { config(['existing-business.enabled' => true]); diff --git a/tests/Feature/ImportDocumentBatchTest.php b/tests/Feature/ImportDocumentBatchTest.php new file mode 100644 index 00000000..7aafeb03 --- /dev/null +++ b/tests/Feature/ImportDocumentBatchTest.php @@ -0,0 +1,88 @@ +batchName = 'test-'.\Illuminate\Support\Str::uuid(); + $this->staging = storage_path('app/private/document-import-staging/'.$this->batchName); + File::ensureDirectoryExists($this->staging); + Storage::fake('local'); + Bus::fake(); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->staging); + parent::tearDown(); + } + + public function test_preview_is_read_only_and_batch_import_is_private_idempotent_and_attributed(): void + { + $pdf = "%PDF-1.4\nfixture document\n%%EOF"; + file_put_contents($this->staging.'/source.pdf', $pdf); + $record = [ + 'external_id' => 'test-document-v1', 'relative_path' => $this->batchName.'/source.pdf', + 'title' => 'Documento de teste', 'issuer' => 'Entidade de teste', 'file_name' => 'documento.pdf', + 'source_url' => 'https://example.test/documento.pdf', 'source_reference' => 'URL de teste; sem validação legal.', + 'document_number' => 'TEST-1', 'document_date' => '2026-01-01', 'language' => 'pt', + 'version_label' => 'edição de teste', 'category' => 'general', 'topic' => 'test', 'document_type' => 'guide', + ]; + file_put_contents($this->staging.'/manifest.json', json_encode(['documents' => [$record, $record]], JSON_THROW_ON_ERROR)); + $actor = \App\Models\User::factory()->create(); + + $this->artisan(new ImportDocumentBatch(), ['manifest' => $this->batchName.'/manifest.json'])->assertSuccessful(); + $this->assertDatabaseCount('documents', 0); + $this->assertDatabaseCount('portal_resources', 0); + $this->assertSame([], Storage::disk('local')->allFiles('portal-documents')); + + $this->artisan(new ImportDocumentBatch(), ['manifest' => $this->batchName.'/manifest.json', '--apply' => true, '--actor' => (string) $actor->id])->assertSuccessful(); + $this->assertDatabaseCount('documents', 1); + $this->assertDatabaseCount('portal_resources', 1); + $document = DB::table('documents')->firstOrFail(); + $this->assertSame($actor->id, $document->uploaded_by); + $this->assertSame('quarantined', $document->status); + $this->assertSame('internal', $document->data_classification); + $this->assertSame('pending', $document->malware_scan_status); + $this->assertDatabaseHas('portal_resources', ['published' => false, 'rights_status' => 'unconfirmed', 'created_by' => $actor->id]); + $this->assertDatabaseHas('audit_events', ['actor_user_id' => $actor->id, 'action' => 'private_document_batch_imported']); + Bus::assertDispatchedTimes(ScanDocumentForMalware::class, 1); + + $this->artisan(new ImportDocumentBatch(), ['manifest' => $this->batchName.'/manifest.json', '--apply' => true, '--actor' => (string) $actor->id])->assertSuccessful(); + $this->assertDatabaseCount('documents', 1); + $this->assertDatabaseCount('portal_resources', 1); + $this->assertDatabaseCount('audit_events', 1); + Bus::assertDispatchedTimes(ScanDocumentForMalware::class, 1); + } + + public function test_apply_refuses_missing_actor_and_source_path_traversal(): void + { + file_put_contents($this->staging.'/source.pdf', "%PDF-1.4\nfixture\n%%EOF"); + $record = ['external_id' => 'test-document', 'relative_path' => '..\\..\\..\\..\\.env', 'title' => 'Test', 'issuer' => 'Test']; + file_put_contents($this->staging.'/manifest.json', json_encode(['documents' => [$record]], JSON_THROW_ON_ERROR)); + + $this->artisan(new ImportDocumentBatch(), ['manifest' => $this->batchName.'/manifest.json', '--apply' => true])->assertFailed(); + $this->assertDatabaseCount('documents', 0); + + $this->artisan(new ImportDocumentBatch(), ['manifest' => $this->batchName.'/manifest.json'])->assertFailed(); + $this->assertDatabaseCount('documents', 0); + } +} diff --git a/tests/Feature/ImportLegalSourceArchiveTest.php b/tests/Feature/ImportLegalSourceArchiveTest.php index 53e15d76..92575b02 100644 --- a/tests/Feature/ImportLegalSourceArchiveTest.php +++ b/tests/Feature/ImportLegalSourceArchiveTest.php @@ -54,9 +54,10 @@ class ImportLegalSourceArchiveTest extends TestCase $this->assertDatabaseCount('documents', $expectedResources); $this->assertDatabaseCount('portal_resources', $expectedResources); $this->assertDatabaseCount('portal_resource_links', 19); - $this->assertDatabaseCount('portal_resource_links', 8, ['target_type' => 'requirement']); + $this->assertSame(8, DB::table('portal_resource_links')->where('target_type', 'requirement')->count()); $this->assertDatabaseCount('portal_resource_revisions', $expectedResources); $this->assertCount($expectedResources, Storage::disk('local')->allFiles('portal-documents')); + $this->assertSame($expectedResources, DB::table('documents')->where('source_system', 'legal-source-archive') ->where('status', 'active')->where('data_classification', 'internal')->where('rights_status', 'unconfirmed') ->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->count()); @@ -93,8 +94,17 @@ class ImportLegalSourceArchiveTest extends TestCase $this->assertDatabaseCount('documents', $expectedResources); $this->assertDatabaseCount('portal_resources', $expectedResources); $this->assertDatabaseCount('portal_resource_links', 19); - $this->assertDatabaseCount('portal_resource_links', 8, ['target_type' => 'requirement']); + $this->assertSame(8, DB::table('portal_resource_links')->where('target_type', 'requirement')->count()); $this->assertDatabaseCount('portal_resource_revisions', $expectedResources); $this->assertCount($expectedResources, Storage::disk('local')->allFiles('portal-documents')); + + $br248 = DB::table('portal_resources')->where('document_number', 'BR n.º 248/2025, Suplemento n.º 1')->firstOrFail(); + DB::table('portal_resource_links')->where('resource_id', $br248->id)->where('target_type', 'requirement') + ->where('target_key', 'fiscal.irpc.lei_12_2025')->delete(); + $this->artisan('documents:import-legal-source-archive', ['--apply' => true])->assertSuccessful(); + $this->assertDatabaseHas('portal_resource_links', ['resource_id' => $br248->id, 'target_type' => 'requirement', + 'target_key' => 'fiscal.irpc.lei_12_2025', 'review_status' => 'confirmed']); + $this->assertDatabaseCount('portal_resource_links', 19); + $this->assertDatabaseCount('portal_resource_revisions', $expectedResources + 1); } } diff --git a/tests/Feature/InvestmentTerritoryTest.php b/tests/Feature/InvestmentTerritoryTest.php index 6125bb40..8ce2e6a2 100644 --- a/tests/Feature/InvestmentTerritoryTest.php +++ b/tests/Feature/InvestmentTerritoryTest.php @@ -149,6 +149,40 @@ class InvestmentTerritoryTest extends TestCase $this->assertNotContains($national->id, $service->query([])->pluck('id')->all()); } + public function test_opportunity_cluster_and_corridor_filters_use_their_public_opportunity_relations(): void + { + $country = $this->location('country'); + $province = $this->location('province', $country); + $otherProvince = $this->location('province', $country); + foreach ([$country, $province, $otherProvince] as $territoryId) { + DB::table('geo_locations')->where('id', $territoryId)->update(['source_system' => 'cartografia-workbook', + 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$territoryId, + 'data_classification' => 'public']); + } + $sector = Sector::create(['name' => 'Public relation test', 'slug' => 'public-relation-test', 'is_active' => true]); + $corridorId = (string) Str::uuid(); + DB::table('development_corridors')->insert(['id' => $corridorId, 'code' => 'QA-CORRIDOR', 'name' => 'QA public corridor', + 'status' => 'active', 'data_classification' => 'public', 'created_at' => now(), 'updated_at' => now()]); + $clusterId = (string) Str::uuid(); + DB::table('economic_clusters')->insert(['id' => $clusterId, 'code' => 'QA-CLUSTER', 'name' => 'QA public cluster', + 'sector' => 'QA', 'status' => 'active', 'data_classification' => 'public', 'created_at' => now(), 'updated_at' => now()]); + $opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'QA-CORRIDOR-OPP', 'slug' => 'qa-corridor-opp', + 'title' => 'QA opportunity linked to corridor', 'status' => 'published', 'published_at' => now()->subMinute(), 'primary_territory_id' => $province]); + DB::table('opportunity_corridors')->insert(['opportunity_id' => $opportunity->id, 'corridor_id' => $corridorId]); + DB::table('opportunity_clusters')->insert(['opportunity_id' => $opportunity->id, 'cluster_id' => $clusterId]); + + $this->getJson('/api/v1/investment/references')->assertOk()->assertJsonFragment(['id' => $corridorId, 'name' => 'QA public corridor']) + ->assertJsonFragment(['id' => $clusterId, 'name' => 'QA public cluster']); + $this->getJson('/api/v1/investment/search?dimension=corridor&province_id='.$province)->assertOk() + ->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $opportunity->id); + $this->getJson('/api/v1/investment/search?corridor_id='.$corridorId.'&province_id='.$province)->assertOk() + ->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $opportunity->id); + $this->getJson('/api/v1/investment/search?cluster_id='.$clusterId)->assertOk() + ->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $opportunity->id); + $this->getJson('/api/v1/investment/search?dimension=corridor&province_id='.$otherProvince)->assertOk() + ->assertJsonPath('total', 0)->assertJsonCount(0, 'data'); + } + public function test_cluster_territory_uses_only_public_validated_project_links_and_project_locations(): void { $country = $this->location('country'); diff --git a/tests/Feature/InvestmentZoneTest.php b/tests/Feature/InvestmentZoneTest.php index 71bc497b..15116160 100644 --- a/tests/Feature/InvestmentZoneTest.php +++ b/tests/Feature/InvestmentZoneTest.php @@ -44,7 +44,7 @@ class InvestmentZoneTest extends TestCase $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 0); $this->postJson($url.'/'.$id, [...$payload, '_method' => 'PUT', 'version_no' => 1, 'name' => 'Changed synthetic zone'])->assertOk()->assertJsonPath('version_no', 2); $this->putJson($url.'/'.$id, [...$payload, 'version_no' => 1])->assertConflict(); - $this->postJson($url.'/'.$id.'/publish', ['version_no' => 2])->assertForbidden(); + $this->postJson($url.'/'.$id.'/publish', ['version_no' => 2])->assertUnprocessable(); $this->actingAs($this->manager())->postJson($url.'/'.$id.'/publish', ['version_no' => 2])->assertUnprocessable(); $this->assertDatabaseCount('investment_zone_revisions', 2); $snapshot = DB::table('investment_zone_revisions')->where('zone_id', $id)->where('version_no', 1)->value('snapshot'); @@ -123,8 +123,7 @@ class InvestmentZoneTest extends TestCase public function test_documented_general_industrial_park_can_publish_without_special_regime_or_operator_and_filters_match_csv(): void { $editor = $this->manager(); - $publisher = $this->manager(); - config(['investment-zones.publishers' => [$publisher->email]]); + config(['investment-zones.publishers' => []]); $country = $this->geoNode('country', 'Synthetic country for park', null); $province = $this->geoNode('province', 'Synthetic province for park', $country); $evidence = [['title' => 'Synthetic municipal project page', 'url' => 'https://example.test/munhava', 'source_type' => 'institutional', @@ -136,7 +135,7 @@ class InvestmentZoneTest extends TestCase 'source_evidence' => $evidence, 'area_components' => [['type' => 'pilot', 'value' => 50, 'unit' => 'ha', 'status' => 'reported', 'source_reference' => 'Synthetic source reports pilot area; not available land.', 'source_date' => null]], ])->assertCreated()->json('id'); - $this->actingAs($publisher)->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); + $this->actingAs($editor)->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); $this->getJson('/api/v2/investment/zones?development_status=planned')->assertOk()->assertJsonPath('records.total', 1) ->assertJsonPath('records.data.0.id', $id)->assertJsonPath('records.data.0.availability_status', 'not_informed'); $csv = $this->get('/api/v2/investment/zones/export.csv?development_status=planned')->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); @@ -148,6 +147,55 @@ class InvestmentZoneTest extends TestCase $this->assertDatabaseHas('investment_zones', ['id' => $id, 'operator_id' => null, 'legal_source_id' => null, 'publication_status' => 'published']); } + public function test_promoter_sourced_industrial_park_can_be_published_as_informational_only(): void + { + $manager = $this->manager(); + config(['investment-zones.publishers' => []]); + $country = $this->geoNode('country', 'Synthetic country for promoter park', null); + $province = $this->geoNode('province', 'Synthetic province for promoter park', $country); + $id = $this->actingAs($manager)->postJson('/api/v2/investment/admin/zones', [ + 'code' => 'QA-PROMOTER-PARK', 'name' => 'Synthetic promoter-sourced park', 'category' => 'industrial_park', + 'physical_category' => 'industrial_park', 'legal_regime' => 'pending', 'development_status' => 'in_development', + 'availability_status' => 'not_informed', + 'description' => 'Informational project record; legal status, operations, incentives and available land are not confirmed.', + 'province_id' => $province, + 'source_evidence' => [['title' => 'Promoter project page', 'url' => 'https://example.test/promoter-park', 'source_type' => 'promoter', + 'supports' => 'The promoter identifies the project and province; no legal designation or land availability claim.', 'source_date' => null]], + ])->assertCreated()->json('id'); + + $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); + $this->getJson('/api/v2/investment/zones?category=industrial_park')->assertOk() + ->assertJsonPath('records.total', 1)->assertJsonPath('records.data.0.id', $id) + ->assertJsonPath('records.data.0.legal_regime', 'pending') + ->assertJsonPath('records.data.0.availability_status', 'not_informed') + ->assertJsonPath('records.data.0.source_evidence.0.source_type', 'promoter'); + $this->assertDatabaseHas('investment_zones', ['id' => $id, 'publication_status' => 'published', 'legal_regime' => 'pending', + 'operator_id' => null, 'legal_source_id' => null]); + } + + public function test_officially_sourced_zee_can_be_published_informationally_by_the_authenticated_author(): void + { + $manager = $this->manager(); + config(['investment-zones.publishers' => []]); + $country = $this->geoNode('country', 'Synthetic country for ZEE', null); + $province = $this->geoNode('province', 'Synthetic province for ZEE', $country); + $id = $this->actingAs($manager)->postJson('/api/v2/investment/admin/zones', [ + 'code' => 'QA-INFO-ZEE', 'name' => 'Synthetic reported ZEE', 'category' => 'zee', + 'physical_category' => 'economic_zone', 'legal_regime' => 'zee', + 'description' => 'Informational record; legal status and incentives remain unconfirmed.', 'province_id' => $province, + 'source_evidence' => [['title' => 'Institutional source', 'url' => 'https://example.test/zee', 'source_type' => 'institutional', + 'supports' => 'Reports the zone identity and province; does not confirm current legal status or incentives.', 'source_date' => null]], + ])->assertCreated()->json('id'); + + $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); + $this->getJson('/api/v2/investment/zones?category=zee')->assertOk()->assertJsonPath('records.total', 1) + ->assertJsonPath('records.data.0.id', $id); + $this->get('/oportunidades?province_id='.$province)->assertOk() + ->assertInertia(fn ($page) => $page->has('references.zones', 1)->where('references.zones.0.id', $id)); + $this->assertDatabaseHas('investment_zones', ['id' => $id, 'editor_id' => $manager->id, 'reviewer_id' => $manager->id, + 'publication_status' => 'published', 'operator_id' => null, 'legal_source_id' => null]); + } + public function test_zone_record_separates_physical_type_and_legal_regime_and_searches_multiple_reviewed_territories_once(): void { $editor = $this->manager(); @@ -280,9 +328,11 @@ class InvestmentZoneTest extends TestCase $organization = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $organization, 'name' => 'Synthetic operator', 'organization_type' => 'private']); $sector = Sector::create(['slug' => 'qa-zone-sector', 'name' => 'Synthetic sector', 'is_active' => true]); - $opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'QA-ZONE', 'slug' => 'qa-zone', 'title' => 'Synthetic investment', 'status' => 'published', 'published_at' => now()->subMinute()]); $zoneCountry = $this->geoNode('country', 'Synthetic Country for zone link', null); $zoneProvince = $this->geoNode('province', 'Synthetic Province for zone link', $zoneCountry); + $opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'QA-ZONE', 'slug' => 'qa-zone', 'title' => 'Synthetic investment', + 'status' => 'published', 'published_at' => now()->subMinute(), 'province' => 'Synthetic Province for zone link', + 'geography_scope' => 'specific', 'primary_territory_id' => $zoneProvince]); $zoneIds = []; foreach (['zee', 'zfi'] as $category) { $this->actingAs($editor); @@ -313,6 +363,11 @@ class InvestmentZoneTest extends TestCase $this->getJson('/api/v1/investment/search?dimension=zfi&view=list')->assertOk()->assertJsonPath('total', 1); $this->getJson('/api/v1/investment/search?dimension=zee&q=not-present')->assertJsonPath('total', 0); $this->getJson('/api/v1/investment/search?dimension=invalid')->assertUnprocessable(); + $this->get('/oportunidades?province_id='.$zoneProvince.'&zee_id='.$zoneIds['zee_id'])->assertOk() + ->assertInertia(fn ($page) => $page->component('investment/catalogue')->has('references.zones', 2) + ->has('results.data', 1)->where('results.data.0.id', $opportunity->id)); + $this->getJson('/api/v2/investment/zones/'.$zoneIds['zee_id'])->assertOk() + ->assertJsonPath('opportunities.total', 1)->assertJsonPath('opportunities.data.0.id', $opportunity->id); $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 2)->assertJsonMissingPath('records.data.0.editor_id'); $this->getJson('/api/v2/investment/zone-map')->assertJsonCount(2, 'features'); $this->seed(InvestmentReferenceSeeder::class); @@ -382,7 +437,8 @@ class InvestmentZoneTest extends TestCase $this->assertDatabaseCount('investment_zone_relations', 3); $this->assertDatabaseCount('investment_items', 0); config(['investment-zones.publishers' => [], 'investment-zones.legal_reviewers' => [], 'investment-zones.gis_reviewers' => []]); - $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertForbidden(); + $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertUnprocessable(); + $this->assertDatabaseHas('investment_zones', ['id' => $id, 'publication_status' => 'draft']); $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 0); } diff --git a/tests/Feature/PortalServicesTest.php b/tests/Feature/PortalServicesTest.php index bc33ba17..63ec126e 100644 --- a/tests/Feature/PortalServicesTest.php +++ b/tests/Feature/PortalServicesTest.php @@ -48,6 +48,28 @@ class PortalServicesTest extends TestCase $this->get('/management/portal-services')->assertRedirect('/login'); } + public function test_document_management_writes_redirect_to_the_management_get_route(): void + { + $manager = User::factory()->create(); + config(['portal.managers' => [$manager->email]]); + $this->actingAs($manager); + + // A stale Referer can point at the write-only resource endpoint. Both + // validation failures and successful writes must finish at a real GET. + $this->from('/management/portal-services/resources/4') + ->post('/management/portal-services/resources/4', ['published' => 'invalid']) + ->assertRedirect(route('services.manage')) + ->assertSessionHasErrors('published'); + $this->get(route('services.manage'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/manage')); + + $this->from('/management/portal-services/resources/4') + ->post('/management/portal-services/resources', []) + ->assertRedirect(route('services.manage')) + ->assertSessionHasErrors('document_id'); + + $this->get('/management/portal-services/resources/4')->assertStatus(405); + } + public function test_support_receipt_is_idempotent_private_and_requires_consent(): void { $owner = User::factory()->create(); @@ -110,19 +132,21 @@ class PortalServicesTest extends TestCase $this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0)); $id = $this->registerDocumentResource($document->id, 'Guia de teste'); $this->assertDatabaseHas('portal_resources', ['id' => $id, 'published' => false, 'rights_status' => 'authorized']); - $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertUnprocessable(); + $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertRedirect(route('services.manage'))->assertSessionHasErrors('published'); // A malware scan releases technical quarantine without changing privacy or editorial publication. DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']); - $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors(); + $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertRedirect(route('services.manage'))->assertSessionHasNoErrors(); $this->get('/documentos/'.$id.'/download')->assertOk()->assertDownload('exemplo.txt'); $this->get('/documentos/'.$id.'?back=%2Fdocumentos%3Ftopic%3DInvestimento')->assertInertia(fn (Assert $page) => $page ->component('portal/document')->where('resource.title', 'Guia de teste')->where('backUrl', '/documentos?topic=Investimento') ->has('resource.links', 3)->missing('resource.storage_key')->missing('resource.sha256')); $this->get('/')->assertInertia(fn (Assert $page) => $page->has('essentialResources', 1)); $this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 1)->where('resources.data.0.title', 'Guia de teste')->missing('resources.data.0.storage_key')); + $this->get('/documentos?q=Guia%20de%20teste&document_type=guide')->assertInertia(fn (Assert $page) => $page + ->component('portal/services')->has('resources.data', 1)->where('resources.data.0.title', 'Guia de teste')); $this->getJson('/api/v1/documents/recommendations?module=simulator&activity_id=CAE-TEST&stage=review')->assertOk()->assertJsonPath('data.0.title', 'Guia de teste'); $this->put('/management/portal-services/resources/'.$id, [...$this->documentResourcePayload($document->id, 'Guia de teste revisto'), 'metadata_version' => 2, 'description' => 'Resumo editado pelo gestor.']) - ->assertSessionHasNoErrors(); + ->assertRedirect(route('services.manage'))->assertSessionHasNoErrors(); $this->assertDatabaseHas('portal_resources', ['id' => $id, 'published' => true, 'title' => 'Guia de teste revisto']); $this->assertDatabaseHas('portal_resource_revisions', ['resource_id' => $id, 'revision_no' => 3, 'change_type' => 'metadata_updated']); DB::table('documents')->where('id', $document->id)->update(['data_classification' => 'internal']); @@ -175,7 +199,7 @@ class PortalServicesTest extends TestCase $this->assertDatabaseCount('portal_resource_links', 20); } - public function test_document_publication_marks_proposals_and_requires_a_confirmed_source(): void + public function test_legislation_with_an_identified_source_can_be_published_for_consultation_without_claiming_confirmation(): void { Storage::fake('local'); $manager = User::factory()->create(); @@ -187,12 +211,77 @@ class PortalServicesTest extends TestCase $this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors(); $id = (int) DB::table('portal_resources')->value('id'); DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']); - $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertUnprocessable(); - $this->put('/management/portal-services/resources/'.$id, [...$payload, 'metadata_version' => 1, 'source_confirmation' => 'official_confirmed']) - ->assertSessionHasNoErrors(); $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors(); $this->get('/legislacao')->assertInertia(fn (Assert $page) => $page->where('resources.data.0.legal_status', 'proposed') - ->where('resources.data.0.source_confirmation', 'official_confirmed')); + ->where('resources.data.0.source_confirmation', 'identified')); + } + + public function test_recorded_user_authorization_publishes_only_eligible_document_scope_in_one_manager_action(): void + { + Storage::fake('local'); + $manager = User::factory()->create(); + config(['portal.managers' => [$manager->email]]); + $this->post('/management/portal-services/disclosure-authorization/apply')->assertRedirect('/login'); + $this->actingAs($manager); + + $resourceIds = []; + $titles = [ + 1 => 'Lei n.º 22/2022, de 28 de Dezembro — alteração ao Código do IVA (republicação)', + 2 => 'Boletim da República n.º 248/2025 — Suplemento n.º 1', + 3 => 'Boletim da República n.º 89, I Série (08-06-2017) — aviso de rectificação da Lei n.º 13/2016', + 4 => 'Codificador da Divisão Político-Administrativa (INE, 07-06-2012)', + ]; + foreach ([1, 2, 3, 4] as $number) { + $this->post('/management/portal-services/upload', [ + 'file' => UploadedFile::fake()->createWithContent("reference-$number.pdf", "document bytes $number"), + 'original_source' => 'https://example.gov.mz/reference', + ])->assertSessionHasNoErrors(); + $document = DB::table('documents')->where('file_name', "reference-$number.pdf")->firstOrFail(); + $resourceIds[] = $this->registerDocumentResource($document->id, $titles[$number], [ + 'document_type' => $number === 3 ? 'legislation' : 'reference_data', + 'legislation' => $number === 3, + 'source_confirmation' => $number === 3 ? 'identified' : 'official_confirmed', + 'legal_status' => $number === 3 ? 'unknown' : 'historical', + 'rights_status' => 'unconfirmed', 'data_classification' => 'internal', + ]); + DB::table('documents')->where('id', $document->id)->update([ + 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $document->sha256, + ]); + } + $this->assertCount(4, $resourceIds); + DB::table('audit_events')->insert([ + 'source_system' => 'user-instruction', 'external_id' => 'document-repository-disclosure-authorization-2026-10-06-v1', + 'data_classification' => 'internal', 'entity_type' => 'portal_document_repository', 'entity_id' => null, + 'action' => 'public_disclosure_authorized', 'actor_user_id' => null, + 'after_data' => json_encode(['resource_ids' => $resourceIds], JSON_THROW_ON_ERROR), + ]); + + $this->post('/management/portal-services/disclosure-authorization/apply') + ->assertRedirect(route('services.manage'))->assertSessionHasNoErrors(); + $this->assertDatabaseCount('portal_resource_revisions', 8); + $this->assertDatabaseCount('audit_events', 13); + $this->assertSame(4, DB::table('portal_resources')->where('published', true)->count()); + $this->assertSame(4, DB::table('documents')->where('data_classification', 'public')->where('rights_status', 'authorized')->count()); + $this->assertDatabaseHas('portal_resources', ['id' => $resourceIds[2], 'published' => true, 'source_confirmation' => 'identified', 'legal_status' => 'unknown']); + $this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[0], 'target_type' => 'requirement', + 'target_key' => 'fiscal.iva.lei_22_2022', 'review_status' => 'confirmed', 'applicability' => 'evidence']); + $this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[1], 'target_type' => 'requirement', + 'target_key' => 'fiscal.iva.lei_10_2025', 'review_status' => 'pending', 'applicability' => 'evidence']); + $this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[2], 'target_type' => 'requirement', + 'target_key' => 'fiscal.iva.lei_13_2016_rectification_2017', 'review_status' => 'pending', 'applicability' => 'evidence']); + $this->assertDatabaseHas('audit_events', ['source_system' => 'portal-services', 'entity_type' => 'portal_resources', + 'actor_user_id' => $manager->id, 'action' => 'published_under_user_authorization']); + $this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 4)); + $this->get('/documentos?q=22%2F2022')->assertInertia(fn (Assert $page) => $page + ->has('resources.data', 1)->where('resources.data.0.id', $resourceIds[0])); + $this->get('/documentos?category=Guia&document_type=legislation')->assertInertia(fn (Assert $page) => $page + ->has('resources.data', 1)->where('resources.data.0.id', $resourceIds[2])); + $this->get('/documentos/'.$resourceIds[2])->assertInertia(fn (Assert $page) => $page->component('portal/document') + ->where('resource.source_confirmation', 'identified')->where('resource.legal_status', 'unknown')); + $this->get('/documentos/'.$resourceIds[0].'/download')->assertOk()->assertDownload('reference-1.pdf'); + $this->assertDatabaseHas('portal_resource_links', ['resource_id' => $resourceIds[0], 'target_type' => 'module', 'target_key' => 'simulator']); + $this->post('/management/portal-services/disclosure-authorization/apply')->assertSessionHasNoErrors(); + $this->assertDatabaseCount('portal_resource_revisions', 8); } public function test_manager_classifies_a_reviewed_document_public_before_separate_publication(): void @@ -286,7 +375,7 @@ class PortalServicesTest extends TestCase $resource = DB::table('portal_resources')->firstOrFail(); $this->assertFalse((bool) $resource->published); $this->assertSame('internal', DB::table('documents')->where('id', $document->id)->value('data_classification')); - $this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertUnprocessable(); + $this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertRedirect(route('services.manage'))->assertSessionHasErrors('published'); $this->get('/documentos')->assertInertia(fn (Assert $page) => $page->has('resources.data', 0)); $this->get('/documentos/'.$resource->id)->assertNotFound(); $this->get('/documentos/'.$resource->id.'/download')->assertNotFound(); @@ -347,6 +436,31 @@ class PortalServicesTest extends TestCase $this->assertDatabaseCount('documents', 1); } + public function test_public_recommendations_return_only_confirmed_requirement_citations(): void + { + Storage::fake('local'); + $manager = User::factory()->create(); + config(['portal.managers' => [$manager->email]]); + $this->actingAs($manager)->post('/management/portal-services/upload', [ + 'file' => UploadedFile::fake()->createWithContent('public-evidence.txt', 'Documentary evidence fixture.'), + ])->assertSessionHasNoErrors(); + $document = DB::table('documents')->firstOrFail(); + $payload = $this->documentResourcePayload($document->id, 'Fontes fiscais QA'); + $payload['associations'][] = ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022', 'relationship' => 'cites', + 'applicability' => 'evidence', 'review_status' => 'confirmed', 'basis_reference' => 'Citação fixture, artigo 17(1), página 3.']; + $payload['associations'][] = ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_10_2025', 'relationship' => 'cites', + 'applicability' => 'evidence', 'review_status' => 'pending', 'basis_reference' => 'Citação ainda não conferida no fixture.']; + $this->post('/management/portal-services/resources', $payload)->assertSessionHasNoErrors(); + $id = (int) DB::table('portal_resources')->where('title', 'Fontes fiscais QA')->value('id'); + DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'data_classification' => 'public']); + $this->post('/management/portal-services/resources/'.$id, ['published' => true])->assertSessionHasNoErrors(); + + $this->getJson('/api/v1/documents/recommendations?module=simulator')->assertOk() + ->assertJsonPath('data.0.documentary_evidence.0.key', 'fiscal.iva.lei_22_2022') + ->assertJsonPath('data.0.documentary_evidence.0.status', 'citation_checked_application_pending') + ->assertJsonMissing(['key' => 'fiscal.iva.lei_10_2025']); + } + private function registerDocumentResource(string $documentId, string $title, array $overrides = []): int { $payload = [...$this->documentResourcePayload($documentId, $title), ...$overrides];