Files
Investidor-V2/app/Console/Commands/ImportLegalSourceArchive.php
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

313 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class ImportLegalSourceArchive extends Command
{
protected $signature = 'documents:import-legal-source-archive {--apply : Copy verified archive files into private storage and register draft metadata}';
protected $description = 'Idempotently imports the known local legal-source PDFs as private, unpublished library drafts';
public function handle(): int
{
$records = $this->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<int, array<string, mixed>> */
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' => [],
],
];
}
}