records(); $apply = (bool) $this->option('apply'); $disk = Storage::disk('local'); $dispatch = []; $failures = 0; foreach ($records as $record) { $sourcePath = base_path($record['archive_path']); $realSource = realpath($sourcePath); $approvedRoots = array_filter([ realpath(base_path('docs/investmoz/simulations/source-archive')), realpath(storage_path('app/private/geo-ine-source-20260928')), ]); if (! $realSource && ($record['optional'] ?? false)) { $this->warn($record['external_id'].': approved local source copy is not present; skipped without changing records.'); continue; } $withinApprovedRoot = $realSource && collect($approvedRoots)->contains(fn (string $root) => str_starts_with(strtolower($realSource), strtolower($root.DIRECTORY_SEPARATOR))); if (! $realSource || ! $withinApprovedRoot || ! is_file($realSource)) { $this->error($record['external_id'].': archive file missing or outside the approved source archive.'); $failures++; continue; } $hash = hash_file('sha256', $realSource); $size = filesize($realSource); $header = file_get_contents($realSource, false, null, 0, 5); if (! is_string($hash) || ! hash_equals($record['expected_sha256'], strtolower($hash)) || $size !== $record['size_bytes'] || $header !== '%PDF-') { $this->error($record['external_id'].': checksum, size, or PDF signature differs from the reviewed manifest.'); $failures++; continue; } $identity = hash('sha256', mb_strtolower(implode('|', array_map('trim', [$record['issuer'], $record['document_number'], $record['document_date'], $record['language'], $record['version_label']])))); $existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first(); if ($existing) { $storedPath = $disk->path($existing->storage_key); $storedReal = realpath($storedPath); $storageRoot = realpath($disk->path('')); if (! $storedReal || ! $storageRoot || ! str_starts_with(strtolower($storedReal), strtolower($storageRoot.DIRECTORY_SEPARATOR)) || ! is_file($storedReal) || ! hash_equals($hash, (string) hash_file('sha256', $storedReal))) { $this->error($record['external_id'].': matching hash exists but the stored copy could not be verified; no changes made.'); $failures++; continue; } $resource = DB::table('portal_resources')->where('document_id', $existing->id)->first(); if (! $resource) { $this->error($record['external_id'].': document hash already exists without its expected library record; manual reconciliation required.'); $failures++; continue; } if (! hash_equals($identity, (string) $resource->document_identity_key)) { $this->error($record['external_id'].': identical bytes exist under a different document identity; manual reconciliation required.'); $failures++; continue; } $missingEvidence = collect(is_array($record['associations'] ?? null) ? $record['associations'] : [])->filter(fn (array $association) => $association['target_type'] === 'requirement') ->reject(fn (array $association) => DB::table('portal_resource_links')->where('resource_id', $resource->id) ->where('target_type', 'requirement')->where('target_key', $association['target_key'])->exists())->values(); if ($apply && $missingEvidence->isNotEmpty()) { DB::transaction(function () use ($resource, $missingEvidence): void { $locked = DB::table('portal_resources')->where('id', $resource->id)->lockForUpdate()->first(); $now = now(); foreach ($missingEvidence as $association) { DB::table('portal_resource_links')->insertOrIgnore(['resource_id' => $resource->id, ...$association, 'created_at' => $now, 'updated_at' => $now]); } $inserted = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement') ->whereIn('target_key', $missingEvidence->pluck('target_key'))->count(); if ($inserted > 0) { $version = (int) $locked->metadata_version + 1; DB::table('portal_resources')->where('id', $resource->id)->update(['metadata_version' => $version, 'updated_at' => $now]); $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' => 'evidence_linked', 'snapshot' => json_encode(['resource' => $snapshot, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => null, 'created_at' => $now]); } }); } $this->line($record['external_id'].' | reused/reconciled | document='.$existing->id.' | published='.($resource->published ? 'yes' : 'no')); continue; } $identityConflict = DB::table('portal_resources')->where('document_identity_key', $identity)->exists(); if ($identityConflict) { $this->error($record['external_id'].': document identity exists with different bytes; manual version reconciliation required.'); $failures++; continue; } $this->line($record['external_id'].' | '.($apply ? 'importing private draft' : 'would import')." | sha256={$hash} | bytes={$size}"); if (! $apply) { continue; } $documentId = (string) Str::uuid(); $storageKey = 'portal-documents/'.$documentId; $sourceHandle = fopen($realSource, 'rb'); if (! is_resource($sourceHandle)) { $this->error($record['external_id'].': source could not be opened.'); $failures++; continue; } try { $stored = $disk->put($storageKey, $sourceHandle); } finally { fclose($sourceHandle); } $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); $this->error($record['external_id'].': private copy failed the post-copy checksum; no database record created.'); $failures++; continue; } try { $result = DB::transaction(function () use ($record, $documentId, $storageKey, $hash, $size, $identity): array { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['legal-source-archive:'.$hash]); if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) { throw new RuntimeException('A matching document was registered concurrently; rerun the import to reconcile it.'); } if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) { throw new RuntimeException('A matching document identity was registered concurrently; manual reconciliation required.'); } $now = now(); DB::table('documents')->insert([ 'id' => $documentId, 'source_system' => 'legal-source-archive', 'external_id' => $record['external_id'], 'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $record['file_name'], 'media_type' => 'application/pdf', 'storage_key' => $storageKey, 'sha256' => $hash, 'status' => 'quarantined', 'size_bytes' => $size, 'issuer' => $record['issuer'], 'original_source' => $record['source_url'], 'issued_on' => $record['document_date'], 'uploaded_by' => null, 'rights_status' => 'unconfirmed', 'malware_scan_status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ]); $resourceId = DB::table('portal_resources')->insertGetId([ 'document_id' => $documentId, 'title' => $record['title'], 'category' => $record['category'] ?? 'legislation', 'topic' => $record['topic'], 'legislation' => $record['is_legislation'] ?? true, 'essential' => false, 'published' => false, 'source_reference' => $record['source_reference'], 'approved_by' => null, 'description' => $record['description'], 'document_type' => $record['document_type'] ?? 'legislation', 'document_number' => $record['document_number'], 'document_date' => $record['document_date'], 'source_url' => $record['source_url'], 'language' => $record['language'], 'version_label' => $record['version_label'], 'territorial_scope' => 'national', 'source_confirmation' => $record['source_confirmation'], 'legal_status' => $record['legal_status'], 'rights_status' => 'unconfirmed', 'last_reviewed_at' => null, 'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => null, 'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now, ]); foreach ($record['associations'] as $association) { $association['review_status'] ??= 'pending'; DB::table('portal_resource_links')->insertOrIgnore([ 'resource_id' => $resourceId, ...$association, 'created_at' => $now, 'updated_at' => $now, ]); } $links = DB::table('portal_resource_links')->where('resource_id', $resourceId)->orderBy('id')->get(); $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' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => null, 'created_at' => $now, ]); return ['document_id' => $documentId, 'resource_id' => $resourceId]; }); $dispatch[] = $result['document_id']; $this->info($record['external_id'].' | created private draft | document='.$result['document_id'].' | resource='.$result['resource_id']); } catch (Throwable $exception) { $disk->delete($storageKey); $this->error($record['external_id'].': import rolled back ('.$exception->getMessage().').'); $failures++; } } foreach ($dispatch as $documentId) { ScanDocumentForMalware::dispatch($documentId)->afterCommit(); } $this->line($apply ? 'New malware scans queued: '.count($dispatch).'.' : 'Preview only; no files or database rows were changed.'); return $failures > 0 ? self::FAILURE : self::SUCCESS; } /** @return array> */ private function records(): array { $simulatorEvidence = fn (string $basis): array => [ ['target_type' => 'module', 'target_key' => 'simulator', 'relationship' => 'supports', 'applicability' => 'evidence', 'condition_text' => 'Consulta documental; a fonte não determina por si só a regra fiscal aplicável.', 'basis_reference' => $basis], ]; return [ [ 'external_id' => 'inm-br250-supplement-2-law-22-2022-v1', 'archive_path' => 'docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf', 'file_name' => 'Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf', 'expected_sha256' => 'ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53', 'size_bytes' => 1834284, 'title' => 'Lei n.º 22/2022, de 28 de Dezembro — alteração ao Código do IVA (republicação)', 'document_number' => 'Lei n.º 22/2022', 'document_date' => '2022-12-28', 'issuer' => 'Assembleia da República', 'language' => 'pt', 'version_label' => '2.º Suplemento do BR n.º 250/2022; diploma alterador, não consolidação integral', 'topic' => 'iva', 'legal_status' => 'amended', 'source_confirmation' => 'official_confirmed', 'source_url' => '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', 'source_reference' => 'INM, 2.º Suplemento do BR n.º 250 e catálogo oficial AT, registo Lei 22 IVA 2022. O ficheiro local foi descarregado novamente do endpoint AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. O artigo 17(1) está citado na revisão documental.', 'description' => 'Diploma alterador do CIVA, sem afirmar consolidação ou vigência integral actual. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.', 'associations' => [...$simulatorEvidence('Lei n.º 22/2022, art. 17(1), conforme revisão documental em docs/investmoz/simulations/primary-text-review-2025-2026.md; consultar alterações posteriores antes de determinar o período.'), ['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null, 'basis_reference' => 'Lei n.º 22/2022, art. 17(1); efeito temporal e alterações posteriores devem ser considerados.'], ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed', 'condition_text' => 'Evidência documental; a citação não activa nem determina cálculos fiscais.', 'basis_reference' => 'Lei n.º 22/2022, art. 17(1), p. 3 do PDF oficial arquivado; a redacção diz 16%. Conferência da citação concluída; aplicação temporal e aprovação fiscal continuam separadas.']], ], [ 'external_id' => 'inm-br248-supplement-1-2025-v1', 'archive_path' => 'docs/investmoz/simulations/source-archive/BR-248-I-Suplemento-1-2025.pdf', 'file_name' => 'BR-248-I-Suplemento-1-2025.pdf', 'expected_sha256' => 'ccf2a8deeca820f2dd076b4b2141b1efe3f6a806568c3a587ca59a8e92f4c251', 'size_bytes' => 440018, 'title' => 'Boletim da República n.º 248/2025 — Suplemento n.º 1', 'document_number' => 'BR n.º 248/2025, Suplemento n.º 1', 'document_date' => '2025-12-29', 'issuer' => 'Imprensa Nacional de Moçambique', 'language' => 'pt', 'version_label' => 'Suplemento n.º 1; edição arquivada com 21 páginas', 'topic' => 'fiscalidade', 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed', 'source_url' => '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', 'source_reference' => 'Ficha INM do Suplemento n.º 1 do BR 248 e catálogo oficial AT. O ficheiro local foi descarregado novamente do endpoint WebDAV AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. Inclui diplomas fiscais identificados no registo de fontes; artigo, vigência e situação de cada lei devem ser consultados individualmente.', 'description' => 'Edição que reúne diplomas sobre ICE, Pauta Aduaneira, ISPC, IVA, IRPS e IRPC. O estado de vigência é mantido por confirmar por diploma e dispositivo. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.', 'associations' => [...$simulatorEvidence('BR n.º 248/2025, Suplemento n.º 1; esta ligação é evidência documental, não confirmação de vigência ou obrigação.'), ...array_map(fn (string $theme) => ['target_type' => 'theme', 'target_key' => $theme, 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null, 'review_status' => 'pending', 'basis_reference' => 'Associação temática antiga; artigo e alcance por diploma ainda não foram especificados.'], ['ice', 'aduaneiro', 'ispc', 'iva', 'irps', 'irpc']), ...array_map(fn (array $item) => ['target_type' => 'requirement', 'target_key' => $item[0], 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed', 'condition_text' => 'Citação localizada no texto primário; não confirma interpretação, vigência integral, elegibilidade nem regra executável.', 'basis_reference' => $item[1].' — '.$item[2].' BR n.º 248/2025, Suplemento n.º 1, Lei de 29-12-2025.'], [ ['fiscal.ice.lei_7_2025', 'Lei n.º 7/2025 (ICE)', 'arts. 1–5 e tabela anexa; pp. 1–2. Art. 1 prorroga as taxas para 2027; art. 5 indica entrada em vigor em 01-01-2026.'], ['fiscal.customs.lei_8_2025', 'Lei n.º 8/2025 (Pauta Aduaneira)', 'art. 1 altera as Instruções Preliminares, arts. 21(2), 22 e 27; arts. 2–5 e tabela anexa; pp. 2–7.'], ['fiscal.ispc.lei_9_2025', 'Lei n.º 9/2025 (ISPC)', 'arts. 1–7 e republicação anexa da Lei n.º 5/2009; pp. 8–11.'], ['fiscal.iva.lei_10_2025', 'Lei n.º 10/2025 (IVA)', 'arts. 1–4; alterações do CIVA identificadas no diploma, incluindo arts. 18, 19, 21, 23, 25-A, 27 e 32–34; pp. 12–15.'], ['fiscal.irps.lei_11_2025', 'Lei n.º 11/2025 (IRPS)', 'arts. 1–4; alterações do CIRPS, incluindo arts. 21, 22, 48, 50, 54-A, 57, 65 e 65-A; pp. 16–18.'], ['fiscal.irpc.lei_12_2025', 'Lei n.º 12/2025 (IRPC)', 'arts. 1–4; alterações do CIRPC, incluindo arts. 3, 5, 20, 62, 67, 75 e aditamento do art. 61-A; pp. 19–20.'], ]), ], ], [ 'external_id' => 'inm-br89-series-1-2017-iva-rectification-v1', 'archive_path' => 'docs/investmoz/simulations/source-archive/BR-89-I-Serie-2017-06-08.pdf', 'file_name' => 'BR-89-I-Serie-2017-06-08.pdf', 'expected_sha256' => '11f0cab15eb6e51010952203ffa24fdc533a24fd642e19525fe4f46b17a1f5e5', 'size_bytes' => 9456358, 'title' => 'Boletim da República n.º 89, I Série (08-06-2017) — aviso de rectificação da Lei n.º 13/2016', 'document_number' => 'BR n.º 89/2017', 'document_date' => '2017-06-08', 'issuer' => 'Imprensa Nacional de Moçambique', 'language' => 'pt', 'version_label' => 'Exemplar local de 16 páginas; autenticidade binária pendente', 'topic' => 'iva', 'legal_status' => 'unknown', 'source_confirmation' => 'identified', 'source_url' => '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', 'source_reference' => 'Ficha oficial INM identifica o aviso de rectificação da Lei n.º 13/2016 na p. 569. O exemplar local não tem assinatura digital validável; cadeia de custódia e efeito temporal por confirmar, conforme docs/investmoz/simulations/official-source-register.md.', 'description' => 'Aviso de rectificação ligado ao CIVA de 2016. A identidade do aviso é corroborada pela ficha INM; autenticidade do ficheiro local, anexos corrigidos e efeito temporal permanecem por confirmar. Cópia mantida em privado.', 'associations' => [...$simulatorEvidence('Ficha INM: aviso de rectificação da Lei n.º 13/2016, p. 569; exemplar local e efeito temporal por confirmar.'), ['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null, 'review_status' => 'pending', 'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; exemplar local, anexos corrigidos e efeito temporal ainda não autenticados.'], ['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_13_2016_rectification_2017', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'pending', 'condition_text' => 'Relação candidata pendente de conferência da cópia e dos anexos rectificados.', 'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; ficha INM identifica o aviso, mas a cópia local e os anexos não foram autenticados.']], ], [ 'external_id' => 'ine-codificador-divisao-politico-administrativa-2012-v1', 'optional' => true, 'archive_path' => 'storage/app/private/geo-ine-source-20260928/original.pdf', 'file_name' => 'INE-Codificador-Divisao-Politico-Administrativa-2012.pdf', 'expected_sha256' => '5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2', 'size_bytes' => 67067, 'title' => 'Codificador da Divisão Político-Administrativa (INE, 07-06-2012)', 'document_number' => 'Codificador DPA 2012', 'document_date' => '2012-06-07', 'issuer' => 'Instituto Nacional de Estatística (INE)', 'language' => 'pt', 'version_label' => 'Edição 2012; actualidade das designações por confirmar', 'topic' => 'território', 'category' => 'Dados de referência', 'document_type' => 'reference_data', 'is_legislation' => false, 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed', 'source_url' => 'https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d', 'source_reference' => 'PDF disponibilizado no portal oficial do INE; download efectuado em 2026-10-06, 67.067 bytes. SHA-256 coincidente com o original privado e com a cópia oficial obtida no portal INE. A data impressa é 07-06-2012; a vigência territorial actual e os direitos de reutilização do ficheiro permanecem por confirmar.', 'description' => 'Documento de referência com códigos e designações político-administrativas na edição indicada. A extracção anterior encontrou colunas com alinhamento ambíguo, sem autorizar correspondências automáticas de postos e distritos; não usar esta edição como catálogo territorial actual nem como fonte de relações não revistas.', 'territorial_scope' => 'national', 'associations' => [], ], ]; } }