+26
-6
@@ -20,12 +20,14 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
# Full application schema requires PostgreSQL with PostGIS; SQLite is unsupported.
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=investemoz
|
||||
# Configure a least-privilege application role; provision PostGIS before migrations.
|
||||
DB_USERNAME=investmoz_app
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@@ -36,6 +38,16 @@ SESSION_DOMAIN=null
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
DB_QUEUE_RETRY_AFTER=210
|
||||
MALWARE_SCANNER=clamd
|
||||
CLAMD_HOST=127.0.0.1
|
||||
CLAMD_PORT=3310
|
||||
CLAMD_TIMEOUT_SECONDS=120
|
||||
CLAMD_MAX_SIGNATURE_AGE_HOURS=48
|
||||
CLAMD_MAX_FILE_BYTES=20971520
|
||||
CLAMD_MAX_STREAM_BYTES=26214400
|
||||
CLAMD_MAX_DECOMPRESSED_BYTES=104857600
|
||||
CLAMD_MAX_ARCHIVE_ENTRIES=1000
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
@@ -94,3 +106,11 @@ SUITECRM_BASE_URL=
|
||||
SUITECRM_CLIENT_ID=
|
||||
SUITECRM_CLIENT_SECRET=
|
||||
SUITECRM_CACHE_STORE=database
|
||||
|
||||
# Tax-rule governance roles are empty by default and require named verified managers with MFA.
|
||||
INVESTMOZ_TAX_RULE_EDITORS=
|
||||
INVESTMOZ_TAX_RULE_REVIEWERS=
|
||||
INVESTMOZ_TAX_RULE_APPROVERS=
|
||||
INVESTMOZ_TAX_RULE_PUBLISHERS=
|
||||
|
||||
INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class CheckDocumentLibrarySchema extends Command
|
||||
{
|
||||
protected $signature = 'portal:check-document-library-schema';
|
||||
|
||||
protected $description = 'Confere o esquema e as migrações da biblioteca documental antes da activação';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$reference = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
$required = [
|
||||
'portal_resources' => [
|
||||
'id', 'document_id', 'title', 'category', 'topic', 'legislation', 'essential', 'published',
|
||||
'source_reference', 'rights_status', 'description', 'document_type', 'document_number',
|
||||
'document_date', 'source_url', 'language', 'version_label', 'document_group_id',
|
||||
'applicable_from', 'applicable_until',
|
||||
'territorial_scope', 'territory_id', 'source_confirmation', 'legal_status', 'last_reviewed_at',
|
||||
'document_identity_key', 'duplicate_of_resource_id', 'metadata_version', 'created_by',
|
||||
'last_reviewed_by', 'independent_review',
|
||||
],
|
||||
'documents' => [
|
||||
'id', 'status', 'data_classification', 'sha256', 'deleted_at', 'file_name', 'media_type',
|
||||
'storage_key', 'size_bytes', 'version_no', 'issuer', 'issued_on', 'valid_until', 'uploaded_by',
|
||||
'rights_status', 'original_source', 'malware_scan_status', 'malware_scanned_sha256',
|
||||
'malware_engine', 'malware_engine_version', 'malware_signatures_at', 'malware_scanned_at',
|
||||
'malware_failure_reason', 'malware_scan_attempts',
|
||||
],
|
||||
'portal_resource_links' => ['id', 'resource_id', 'target_type', 'target_key', 'relationship', 'applicability'],
|
||||
'portal_resource_revisions' => ['id', 'resource_id', 'revision_no', 'change_type', 'snapshot'],
|
||||
'document_malware_scans' => ['id', 'document_id', 'file_sha256', 'result', 'engine', 'engine_version', 'started_at'],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
foreach ($required as $table => $columns) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
$missing[] = "tabela {$table}";
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_diff($columns, Schema::getColumnListing($table)) as $column) {
|
||||
$missing[] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
|
||||
$requiredMigrations = [
|
||||
'2026_10_05_120000_extend_portal_document_catalogue',
|
||||
'2026_10_05_130000_add_document_malware_scanning',
|
||||
];
|
||||
if (! Schema::hasTable('migrations')) {
|
||||
$missing[] = 'repositório de migrações';
|
||||
} else {
|
||||
$ran = DB::table('migrations')->whereIn('migration', $requiredMigrations)->pluck('migration')->all();
|
||||
foreach (array_diff($requiredMigrations, $ran) as $migration) {
|
||||
$missing[] = "migração {$migration}";
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
$this->error('Esquema da biblioteca incompatível. Não activar o catálogo; faltam elementos de esquema ou migrações.');
|
||||
foreach ($missing as $item) {
|
||||
$this->line('- '.$item);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['portal_resources', 'rights_status', 'unconfirmed'],
|
||||
['documents', 'rights_status', 'unconfirmed'],
|
||||
['documents', 'malware_scan_status', 'pending'],
|
||||
] as [$table, $column, $expectedDefault]) {
|
||||
$default = DB::selectOne(
|
||||
'select column_default from information_schema.columns where table_schema = current_schema() and table_name = ? and column_name = ?',
|
||||
[$table, $column],
|
||||
)?->column_default;
|
||||
if (! is_string($default) || ! str_contains(strtolower($default), $expectedDefault)) {
|
||||
$this->error("Valor predefinido inseguro ou ausente em {$table}.{$column}; preservar os registos e rever a migração.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$invalidCleanRecords = DB::table('documents')->where('malware_scan_status', 'clean')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('malware_scanned_sha256')->orWhereColumn('malware_scanned_sha256', '!=', 'sha256');
|
||||
})->count();
|
||||
if ($invalidCleanRecords > 0) {
|
||||
$this->error('Existem documentos marcados como limpos sem correspondência do hash analisado.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Esquema da biblioteca compatível; verificações de direitos e análise antimalware disponíveis.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Document library schema compatibility check failed.', ['reference' => $reference, 'exception' => $exception]);
|
||||
$this->error('Não foi possível verificar a compatibilidade do esquema. Consulte o registo interno. Referência: '.$reference);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckMalwareScanner extends Command
|
||||
{
|
||||
protected $signature = 'malware:health';
|
||||
|
||||
protected $description = 'Check ClamAV availability and signature freshness';
|
||||
|
||||
public function handle(MalwareScanner $scanner): int
|
||||
{
|
||||
$health = $scanner->health();
|
||||
$this->line('Scanner: '.($health['engine'] ?? 'ClamAV'));
|
||||
$this->line('Available: '.($health['available'] ? 'yes' : 'no'));
|
||||
$this->line('Engine version: '.($health['engine_version'] ?? 'unavailable'));
|
||||
$this->line('Signatures UTC: '.($health['signatures_at']?->format(DATE_ATOM) ?? 'unavailable'));
|
||||
$this->line('Signature age hours: '.($health['signature_age_hours'] === null ? 'unavailable' : number_format($health['signature_age_hours'], 2)));
|
||||
if (! $health['available']) {
|
||||
$this->error('Reason: '.($health['reason_code'] ?? 'scanner_unavailable'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ImportIndustrialParkCandidates extends Command
|
||||
{
|
||||
protected $signature = 'investment-zones:import-industrial-candidates {--apply : Create missing private drafts} {--actor= : Existing eligible manager user ID required with --apply}';
|
||||
|
||||
protected $description = 'Reconcile and optionally create source-backed industrial-park/ZFI drafts without publishing';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$document = json_decode(file_get_contents(database_path('data/industrial-park-candidate-drafts.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
if (($document['source_document_attached'] ?? null) !== true
|
||||
|| ($document['source_document_reconciled'] ?? null) !== true) {
|
||||
$this->error('The source document must be attached and reconciled before importing drafts. No records written.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($document['records'] as $record) {
|
||||
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'publication_status']);
|
||||
$territories = [];
|
||||
foreach ($record['territories'] as $territory) {
|
||||
$expected = $this->normalize($territory['name']);
|
||||
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
|
||||
->get(['id', 'name'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
|
||||
if ($matches->count() !== 1) {
|
||||
throw new RuntimeException("Territory {$territory['name']} ({$territory['type']}) matched {$matches->count()} rows; approximate matching is disabled.");
|
||||
}
|
||||
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
|
||||
}
|
||||
$rows[] = ['record' => $record, 'existing' => $existing, 'territories' => $territories];
|
||||
}
|
||||
$this->table(['Code', 'Action', 'Territory IDs resolved', 'Areas'], array_map(fn ($row) => [
|
||||
$row['record']['code'], $row['existing'] ? 'reconcile/skip-existing' : ($this->option('apply') ? 'create-private-draft' : 'would-create-private-draft'),
|
||||
implode('; ', array_map(fn ($t) => $t['resolved_name'], $row['territories'])), count($row['record']['areas']),
|
||||
], $rows));
|
||||
|
||||
if (! $this->option('apply')) {
|
||||
$this->info('Dry run only. No writes made; every created record remains a private draft, with legal regime/availability not implied by the source.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
|
||||
$this->error('The additive industrial-park schema is not migrated in this database. No records written.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
|
||||
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
|
||||
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|
||||
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
|
||||
$this->error('An existing verified, MFA-enabled configured manager is required. No records written.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$created = DB::transaction(function () use ($rows, $actor, $document): array {
|
||||
$created = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['existing']) {
|
||||
continue;
|
||||
}
|
||||
$record = $row['record'];
|
||||
$source = $record['source'];
|
||||
$sourceReference = $source['url'].' — '.$source['title'];
|
||||
$locations = array_map(fn ($location) => ['geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
|
||||
'source_reference' => $sourceReference, 'legal_source_id' => null], $row['territories']);
|
||||
$first = $row['territories'][0]['geo_location_id'] ?? null;
|
||||
$zone = app(InvestmentZones::class)->save(null, [
|
||||
'code' => $record['code'], 'name' => $record['name'], 'category' => $record['category'],
|
||||
'physical_category' => $record['physical_category'], 'legal_regime' => $record['legal_regime'],
|
||||
'development_status' => $record['development_status'], 'availability_status' => $record['availability_status'],
|
||||
'operational_status' => 'unknown', 'description' => $record['source']['supports'].(! empty($record['territory_note']) ? ' '.$record['territory_note'] : ''),
|
||||
'source_evidence' => [[...$source, 'source_date' => null]],
|
||||
'area_components' => array_map(fn ($area) => [...$area, 'source_reference' => $sourceReference.' — '.$area['source_reference'], 'source_date' => null], $record['areas']),
|
||||
'area_value' => null, 'area_unit' => null, 'activities' => [], 'resources' => [], 'aliases' => [],
|
||||
'sources_consulted_at' => $document['consulted_at'],
|
||||
'legal_source_id' => null, 'parent_zone_id' => null, 'primary_territory_id' => $first,
|
||||
'territory_version' => $first ? 1 : 0, 'locations' => $locations,
|
||||
], (int) $actor);
|
||||
$created[] = [$zone->code, $zone->id];
|
||||
}
|
||||
|
||||
return $created;
|
||||
});
|
||||
$this->info('Private drafts created: '.count($created).'. Existing codes skipped. No legal sources were approved and no records were published.');
|
||||
foreach ($created as [$code, $id]) {
|
||||
$this->line($code.' '.$id);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return Str::upper(Str::ascii(trim($value)));
|
||||
}
|
||||
}
|
||||
@@ -5,54 +5,18 @@ namespace App\Console\Commands;
|
||||
use App\Models\InvestmentDossier;
|
||||
use Database\Seeders\InvestmentPortfolioSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ImportInvestmentPortfolio extends Command
|
||||
{
|
||||
protected $signature = 'portfolio:import {--approval= : Recorded approval from the document owner}';
|
||||
protected $signature = 'portfolio:import';
|
||||
|
||||
protected $description = 'Import the v4 portfolio and optionally publish unchanged documentary dossiers with owner approval';
|
||||
protected $description = 'Import the documentary portfolio as private drafts; publication uses the authenticated review workflow';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$approval = trim((string) $this->option('approval'));
|
||||
if ($approval !== '' && (mb_strlen($approval) < 15 || mb_strlen($approval) > 3000)) {
|
||||
$this->error('A autorização deve conter entre 15 e 3000 caracteres.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$data = json_decode(File::get(database_path('data/investment-portfolio.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
$published = 0;
|
||||
$preserved = 0;
|
||||
DB::transaction(function () use ($data, $approval, &$published, &$preserved) {
|
||||
app(InvestmentPortfolioSeeder::class)->run();
|
||||
foreach (['projects' => ['project_id', 'projects'], 'clusters' => ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors']] as $kind => [$foreign, $table]) {
|
||||
foreach ($data[$kind] as $item) {
|
||||
$entity = DB::table($table)->where('code', $item['code'])->where('source_system', 'upgrade-v4')->whereNull('deleted_at')->value('id');
|
||||
$dossier = InvestmentDossier::where($foreign, $entity)->lockForUpdate()->first();
|
||||
// Approval of this document never approves subsequent human edits or restores archived records.
|
||||
if (! $dossier || $dossier->title !== $item['name'] || $dossier->content != [...$item, 'notice' => $data['notice']] || $dossier->editor_id) {
|
||||
$preserved++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($approval === '' || $dossier->publication_status === 'published') {
|
||||
continue;
|
||||
}
|
||||
$dossier->update(['version_no' => $dossier->version_no + 1, 'publication_status' => 'published', 'published_at' => now(),
|
||||
'approval_basis' => 'document_owner', 'approval_evidence' => $approval]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'portfolio-import', 'entity_type' => 'investment_dossiers',
|
||||
'entity_id' => $dossier->id, 'action' => 'owner_approved', 'after_data' => json_encode([
|
||||
'code' => $item['code'], 'approval_basis' => 'document_owner',
|
||||
'approval_sha256' => hash('sha256', $approval),
|
||||
'dataset_sha256' => hash_file('sha256', database_path('data/investment-portfolio.json')),
|
||||
], JSON_THROW_ON_ERROR)]);
|
||||
$published++;
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->info("Carteira importada: 15 projectos, 8 clusters, 6 corredores. Publicados: {$published}. Fichas alteradas/arquivadas preservadas: {$preserved}.");
|
||||
app(InvestmentPortfolioSeeder::class)->run();
|
||||
$drafts = InvestmentDossier::where('publication_status', 'draft')->count();
|
||||
$this->info("Carteira importada/actualizada. Dossiers em rascunho privado: {$drafts}. Nenhuma publicação foi realizada.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ImportInvestmentZones extends Command
|
||||
{
|
||||
protected $signature = 'investment-zones:import-initial {--apply : Create missing records as private drafts} {--actor= : Existing manager user ID required with --apply}';
|
||||
|
||||
protected $description = 'Import documented initial ZEE, ZFI and industrial park references without overwriting or publishing existing records';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = database_path('data/investment-zones-initial.json');
|
||||
$document = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
|
||||
$rows = [];
|
||||
foreach ($document['records'] as $record) {
|
||||
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'category', 'publication_status']);
|
||||
$territories = [];
|
||||
foreach ($record['territories'] as $territory) {
|
||||
$expected = $this->normalize($territory['name']);
|
||||
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
|
||||
->get(['id', 'name', 'data_classification'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
|
||||
if ($matches->count() !== 1) {
|
||||
throw new RuntimeException("Territory reference {$territory['name']} ({$territory['type']}) matched {$matches->count()} master records; no approximate match or new territory was created.");
|
||||
}
|
||||
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
|
||||
}
|
||||
$rows[] = ['code' => $record['code'], 'name' => $record['name'], 'action' => $existing ? 'skip-existing' : ($this->option('apply') ? 'create-draft' : 'would-create-draft'), 'existing' => $existing, 'territories' => $territories];
|
||||
}
|
||||
|
||||
if (! $this->option('apply')) {
|
||||
$this->table(['Code', 'Name', 'Action', 'Territories'], array_map(fn ($row) => [$row['code'], $row['name'], $row['action'], implode('; ', array_column($row['territories'], 'resolved_name'))], $rows));
|
||||
$this->info('Dry run: no records written. Sources are official references; records remain private drafts pending review.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
|
||||
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
|
||||
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|
||||
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
|
||||
$this->error('An existing, verified, MFA-enabled portal manager is required with --apply --actor=.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$summary = DB::transaction(function () use ($rows, $document, $actor): array {
|
||||
$created = [];
|
||||
$skipped = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['existing']) {
|
||||
$skipped[] = $row['code'];
|
||||
|
||||
continue;
|
||||
}
|
||||
$record = collect($document['records'])->firstWhere('code', $row['code']);
|
||||
$requiresLegalSource = in_array($record['category'], ['zee', 'zfi'], true);
|
||||
$source = $requiresLegalSource
|
||||
? DB::table('investment_legal_sources')->where('url', $record['source']['url'])->where('diploma', $record['source']['diploma'])->first()
|
||||
: null;
|
||||
$sourceId = $requiresLegalSource ? ($source?->id ?? (string) Str::uuid()) : null;
|
||||
if ($requiresLegalSource && ! $source) {
|
||||
DB::table('investment_legal_sources')->insert([
|
||||
'id' => $sourceId, ...$record['source'], 'validation_status' => 'pending', 'owner_id' => $actor,
|
||||
'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
$parentId = null;
|
||||
if (isset($record['parent_code'])) {
|
||||
$parentId = DB::table('investment_zones')->where('code', $record['parent_code'])->value('id');
|
||||
if (! $parentId) {
|
||||
throw new RuntimeException("Parent zone {$record['parent_code']} must be imported before {$record['code']}.");
|
||||
}
|
||||
}
|
||||
$locations = array_map(fn ($location) => [
|
||||
'geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
|
||||
'source_reference' => $record['source']['url'].' — '.($record['source']['diploma'] ?? $record['source']['title']), 'legal_source_id' => $sourceId,
|
||||
], $row['territories']);
|
||||
$firstLocation = $row['territories'][0]['geo_location_id'] ?? null;
|
||||
$zone = app(InvestmentZones::class)->save(null, [
|
||||
'code' => $record['code'], 'category' => $record['category'], 'physical_category' => $record['physical_category'],
|
||||
'legal_regime' => $record['legal_regime'], 'operational_status' => $record['operational_status'],
|
||||
'development_status' => $record['development_status'] ?? match ($record['operational_status']) {
|
||||
'operational' => 'operational', 'implementation' => 'in_development', 'planned' => 'planned', default => 'unknown'
|
||||
},
|
||||
'availability_status' => $record['availability_status'] ?? 'not_informed',
|
||||
'name' => $record['name'], 'name_en' => $record['name_en'], 'description' => $record['description'], 'description_en' => $record['description_en'],
|
||||
'area_value' => $record['area_value'], 'area_unit' => $record['area_unit'], 'area_components' => $record['area_components'] ?? [],
|
||||
'source_evidence' => [[
|
||||
'title' => $record['source']['title'], 'url' => $record['source']['url'],
|
||||
'source_type' => str_contains($record['source']['url'], 'inm.gov.mz') ? 'government' : (str_contains($record['source']['url'], 'apiex.gov.mz') ? 'institutional' : 'promoter'),
|
||||
'supports' => $record['source']['supports'] ?? 'Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade.',
|
||||
'source_date' => $record['source']['source_date'] ?? null,
|
||||
]], 'aliases' => $record['aliases'],
|
||||
'activities' => $record['activities'], 'resources' => [], 'historical_territory' => $record['historical_territory'] ?? null,
|
||||
'data_as_of' => null, 'sources_consulted_at' => $document['consulted_at'], 'operator_id' => null,
|
||||
'legal_source_id' => $sourceId, 'parent_zone_id' => $parentId, 'primary_territory_id' => $firstLocation,
|
||||
'territory_version' => $firstLocation ? 1 : 0, 'province' => null, 'district' => null, 'locations' => $locations,
|
||||
], (int) $actor);
|
||||
$created[] = ['code' => $record['code'], 'id' => $zone->id, 'source' => $source?->id ? 'reused' : 'created', 'locations_pending' => count($locations)];
|
||||
}
|
||||
|
||||
return compact('created', 'skipped');
|
||||
});
|
||||
|
||||
$this->table(['Code', 'ID', 'Source', 'Pending locations'], array_map(fn ($row) => [$row['code'], $row['id'], $row['source'], $row['locations_pending']], $summary['created']));
|
||||
$this->info('Created drafts: '.count($summary['created']).'; skipped without overwrite: '.count($summary['skipped']).'; publication: none.');
|
||||
$this->line('Skipped: '.implode(', ', $summary['skipped']));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return Str::upper(Str::ascii(trim($value)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
<?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($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' => 'requirement_evidence_associated',
|
||||
'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' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ScanPendingDocuments extends Command
|
||||
{
|
||||
protected $signature = 'documents:scan-pending {--apply : Dispatch scans to the queue} {--limit=100 : Maximum documents to process in this batch}';
|
||||
|
||||
protected $description = 'List or dispatch a controlled batch of document malware scans';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, min(1000, (int) $this->option('limit')));
|
||||
$ids = DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
|
||||
->where(function ($query): void {
|
||||
$query->whereIn('malware_scan_status', ['pending', 'inconclusive', 'failed'])
|
||||
->orWhere(fn ($stale) => $stale->where('malware_scan_status', 'scanning')->where('updated_at', '<', now()->subMinutes(15)));
|
||||
})->orderBy('created_at')->limit($limit)->pluck('id');
|
||||
|
||||
$this->info(count($ids).' document(s) eligible for this batch.');
|
||||
if (! $this->option('apply')) {
|
||||
$this->comment('Dry run only. Add --apply to dispatch scans.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (DB::table('documents')->where('id', $id)->where('malware_scan_status', 'scanning')->exists()) {
|
||||
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending', 'status' => 'quarantined', 'updated_at' => now()]);
|
||||
}
|
||||
ScanDocumentForMalware::dispatch((string) $id);
|
||||
}
|
||||
|
||||
$this->info('Queued '.$ids->count().' scan(s). No editorial publication state was changed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\AiRun;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\Ai\AiGateway;
|
||||
@@ -25,6 +26,7 @@ final class AiController extends Controller
|
||||
'controls' => DB::table('ai_controls')->orderBy('capability')->get(),
|
||||
'sources' => DB::table('ai_sources')->orderByDesc('created_at')->limit(50)->get(['id', 'title', 'language', 'status', 'nature', 'valid_until', 'submitted_by']),
|
||||
'documents' => DB::table('documents')->where('status', 'active')->whereNull('deleted_at')->where('media_type', 'text/plain')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
|
||||
->whereIn('data_classification', ['public', 'internal'])->limit(100)->get(['id', 'file_name']),
|
||||
'runs' => AiRun::orderByDesc('created_at')->limit(20)->get(['id', 'capability', 'mode', 'review_status', 'review_requested', 'created_at']),
|
||||
'today' => now()->toDateString(),
|
||||
@@ -118,7 +120,8 @@ final class AiController extends Controller
|
||||
DB::transaction(function () use ($request, $file, $text, $id, $key): void {
|
||||
DB::table('documents')->insert(['id' => $id, 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text),
|
||||
'status' => 'active', 'data_classification' => 'internal']);
|
||||
'size_bytes' => strlen($text), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'internal']);
|
||||
$this->audit('documents', $id, 'plain_text_registered', $request->user()->id);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -126,6 +129,8 @@ final class AiController extends Controller
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['document_id' => $id], 201)
|
||||
: back()->with('success', 'Texto guardado no cadastro documental. Registe a fonte e solicite revisão antes de o pesquisar.');
|
||||
}
|
||||
@@ -140,6 +145,7 @@ final class AiController extends Controller
|
||||
if ($data['status'] === 'approved') {
|
||||
abort_if((int) $source->submitted_by === $request->user()->id, 403, 'Outra pessoa deve validar a autorização.');
|
||||
$valid = DB::table('documents')->where('id', $source->document_id)->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
|
||||
->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
|
||||
->where('version_no', $source->document_version)->where('sha256', $source->document_checksum)->exists();
|
||||
abort_unless($valid && $source->valid_until >= now()->toDateString(), 422, 'Fonte alterada, expirada ou sem autorização.');
|
||||
|
||||
@@ -15,7 +15,7 @@ class ReferenceCatalogController extends Controller
|
||||
|
||||
public function index(Request $request, string $catalog): JsonResponse
|
||||
{
|
||||
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
|
||||
$catalog = $this->validatedCatalog($catalog);
|
||||
|
||||
$rules = [
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
@@ -71,7 +71,7 @@ class ReferenceCatalogController extends Controller
|
||||
};
|
||||
|
||||
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
|
||||
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, $row))->values();
|
||||
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
@@ -156,32 +156,47 @@ class ReferenceCatalogController extends Controller
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param list<string> $columns */
|
||||
/** @param list<literal-string> $columns */
|
||||
private function search(Builder $query, string $search, array $columns): void
|
||||
{
|
||||
$term = '%'.addcslashes($search, '%_\\').'%';
|
||||
$query->where(function (Builder $nested) use ($columns, $term): void {
|
||||
foreach ($columns as $index => $column) {
|
||||
$method = $index === 0 ? 'whereRaw' : 'orWhereRaw';
|
||||
$nested->{$method}($column.' ILIKE ?', [$term]);
|
||||
if ($index === 0) {
|
||||
$nested->whereRaw($column.' ILIKE ?', [$term]);
|
||||
} else {
|
||||
$nested->orWhereRaw($column.' ILIKE ?', [$term]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function present(string $catalog, object $row): array
|
||||
/** @return 'authorities'|'jurisdictions'|'economic-activities' */
|
||||
private function validatedCatalog(string $catalog): string
|
||||
{
|
||||
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'authorities'|'jurisdictions'|'economic-activities' $catalog
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function present(string $catalog, array $row): array
|
||||
{
|
||||
return match ($catalog) {
|
||||
'jurisdictions' => ['id' => $row->id, 'value' => $row->id, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'type' => $row->location_type,
|
||||
'source_code' => $row->source_code, 'version_no' => $row->version_no],
|
||||
'economic-activities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
|
||||
'version_no' => $row->version_no],
|
||||
'authorities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'authority_type' => $row->authority_type,
|
||||
'organization_id' => $row->organization_id, 'jurisdiction_id' => $row->jurisdiction_id,
|
||||
'jurisdiction_name' => $row->jurisdiction_name, 'version_no' => $row->version_no],
|
||||
'jurisdictions' => ['id' => $row['id'], 'value' => $row['id'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'type' => $row['location_type'],
|
||||
'source_code' => $row['source_code'], 'version_no' => $row['version_no']],
|
||||
'economic-activities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'sector_id' => $row['sector_id'],
|
||||
'version_no' => $row['version_no']],
|
||||
'authorities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'authority_type' => $row['authority_type'],
|
||||
'organization_id' => $row['organization_id'], 'jurisdiction_id' => $row['jurisdiction_id'],
|
||||
'jurisdiction_name' => $row['jurisdiction_name'], 'version_no' => $row['version_no']],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSimulationRequest;
|
||||
use App\Http\Requests\UpdateSimulationScenarioRequest;
|
||||
use App\Models\QuestionnaireVersion;
|
||||
use App\Models\Simulation;
|
||||
use App\Models\SimulationJob;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
@@ -65,6 +66,56 @@ class SimulationController extends Controller
|
||||
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
|
||||
}
|
||||
|
||||
public function history(Request $request, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user !== null, 401);
|
||||
abort_unless($user->hasVerifiedEmail(), 403, 'Verifique o correio electrónico para consultar o histórico.');
|
||||
$perPage = min(50, max(1, $request->integer('per_page', 20)));
|
||||
$page = max(1, $request->integer('page', 1));
|
||||
|
||||
$history = DB::transaction(function () use ($execution, $user, $perPage, $page) {
|
||||
$execution->setContext($user->id, null);
|
||||
|
||||
return Simulation::query()
|
||||
->select([
|
||||
'simulations.id', 'simulations.organization_id', 'visible_organization.name as organization_name',
|
||||
'simulations.status', 'simulations.version_no', 'simulations.created_at', 'simulations.updated_at',
|
||||
])
|
||||
->leftJoin('organizations as visible_organization', 'visible_organization.id', '=', 'simulations.organization_id')
|
||||
->where(function ($scope) use ($user) {
|
||||
$scope->where(fn ($personal) => $personal->whereNull('simulations.organization_id')->where('simulations.owner_user_id', $user->id))
|
||||
->orWhere(fn ($organization) => $organization->whereNotNull('simulations.organization_id')->whereExists(
|
||||
fn ($membership) => $membership->selectRaw('1')->from('memberships')
|
||||
->whereColumn('memberships.organization_id', 'simulations.organization_id')
|
||||
->where('memberships.user_id', $user->id)->whereNull('memberships.deleted_at'),
|
||||
));
|
||||
})
|
||||
->withExists(['reports as has_ready_report' => fn ($query) => $query->where('status', 'ready')])
|
||||
->orderByDesc('simulations.created_at')->orderByDesc('simulations.id')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $history->getCollection()->map(fn (Simulation $simulation): array => [
|
||||
'id' => $simulation->id,
|
||||
'scope' => $simulation->organization_id === null ? 'personal' : 'organization',
|
||||
'organization_name' => $simulation->getAttribute('organization_name'),
|
||||
'status' => $simulation->status,
|
||||
'version_no' => $simulation->version_no,
|
||||
'created_at' => $simulation->created_at?->toISOString(),
|
||||
'updated_at' => $simulation->updated_at?->toISOString(),
|
||||
'has_ready_report' => (bool) $simulation->getAttribute('has_ready_report'),
|
||||
]),
|
||||
'meta' => [
|
||||
'current_page' => $history->currentPage(),
|
||||
'last_page' => $history->lastPage(),
|
||||
'per_page' => $history->perPage(),
|
||||
'total' => $history->total(),
|
||||
],
|
||||
])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AttachSimulationToDraftRequest;
|
||||
use App\Http\Requests\ClaimSimulationFormDraftRequest;
|
||||
use App\Http\Requests\LinkSimulationDraftBusinessRequest;
|
||||
use App\Http\Requests\StoreSimulationFormDraftRequest;
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
use App\Services\SimulationFormDrafts;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SimulationFormDraftController extends Controller
|
||||
@@ -16,13 +21,65 @@ class SimulationFormDraftController extends Controller
|
||||
{
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$result = $drafts->create($request->validated(), $request, $key);
|
||||
$result = $drafts->create($request->snapshots(), $request, $key);
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']]], $result['replayed'] ? 200 : 201)
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']], 'expires_at' => $draft->expires_at?->toISOString()], $result['replayed'] ? 200 : 201)
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function index(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 401);
|
||||
|
||||
return response()->json(['data' => $drafts->ownedDrafts($request->user())])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function claimPreview(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 401);
|
||||
|
||||
return response()->json(['data' => $drafts->claimPreview($request)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function claim(ClaimSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$result = $drafts->claim($request, $request->validated('choice'), $request->validated('existing_draft_id'));
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft, true), 'choice' => $result['choice'], 'replayed' => $result['replayed'] ?? false, 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function linkableBusinesses(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()?->hasVerifiedEmail(), 403);
|
||||
|
||||
return response()->json(['data' => $drafts->linkableBusinesses($request->user())])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function businessLink(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
|
||||
|
||||
return response()->json(['data' => $drafts->activeBusinessLink($request->user(), $id)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function linkBusiness(LinkSimulationDraftBusinessRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$result = $drafts->linkBusiness($request->user(), $id, $request->validated('business_id'));
|
||||
|
||||
return response()->json(['data' => $result], isset($result['replayed']) ? 200 : 201)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function unlinkBusiness(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
|
||||
|
||||
return response()->json(['data' => $drafts->unlinkBusiness($request->user(), $id)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$draft = $drafts->current($request);
|
||||
@@ -30,7 +87,14 @@ class SimulationFormDraftController extends Controller
|
||||
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function forgetCurrent(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$drafts->forgetCurrent($request);
|
||||
|
||||
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
@@ -38,18 +102,82 @@ class SimulationFormDraftController extends Controller
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->find($id, $request);
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->update($id, $request->validated(), $request);
|
||||
$draft = $drafts->update($id, $request->snapshots(), $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no]])
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function renew(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->renew($id, $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function attachSimulation(AttachSimulationToDraftRequest $request, string $id, SimulationFormDrafts $drafts, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$validated = $request->validated();
|
||||
$simulation = $execution->find($validated['simulation_id'], $request);
|
||||
$tokenOwned = $simulation->anonymous_token_hash !== null
|
||||
&& hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request));
|
||||
if ($request->user()) {
|
||||
$policy = app(SimulationPolicy::class);
|
||||
$canTransfer = $simulation->organization_id === null
|
||||
? $policy->view($request->user(), $simulation)
|
||||
: $policy->update($request->user(), $simulation);
|
||||
abort_unless($canTransfer || $tokenOwned, 404);
|
||||
} else {
|
||||
abort_unless($tokenOwned, 404);
|
||||
}
|
||||
abort_unless($simulation->status === 'completed' && is_array($simulation->result_snapshot), 409, 'Só é possível reutilizar uma simulação concluída.');
|
||||
$report = $simulation->reports->where('status', 'ready')->sortByDesc('created_at')->first();
|
||||
abort_unless($report !== null && is_array($report->report_snapshot), 409, 'O relatório versionado da simulação ainda não está disponível.');
|
||||
abort_unless(preg_match('/^[a-f0-9]{64}$/D', $report->report_sha256) === 1, 409,
|
||||
'O relatório da simulação não tem um hash verificável.');
|
||||
$canonicalSnapshots = DB::selectOne('select CAST(? AS jsonb)::text as report_canonical, CAST(? AS jsonb)::text as result_canonical', [
|
||||
json_encode($report->report_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
json_encode($simulation->result_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
abort_unless(hash_equals($report->report_sha256, hash('sha256', $canonicalSnapshots->report_canonical))
|
||||
&& hash_equals($canonicalSnapshots->report_canonical, $canonicalSnapshots->result_canonical), 409,
|
||||
'O relatório não corresponde ao resultado versionado da simulação.');
|
||||
$inputSnapshot = array_intersect_key($simulation->input_snapshot, array_flip(['base', 'rate', 'reduction']));
|
||||
|
||||
$sourceSnapshot = [
|
||||
'simulation_id' => $simulation->id,
|
||||
'source_version_no' => $simulation->version_no,
|
||||
'source_created_at' => $simulation->created_at?->toISOString(),
|
||||
'source_updated_at' => $simulation->updated_at?->toISOString(),
|
||||
'input_snapshot' => $inputSnapshot,
|
||||
'calculation_snapshot' => $simulation->calculation_snapshot,
|
||||
'result_snapshot' => $simulation->result_snapshot,
|
||||
'result_sha256' => $report->report_sha256,
|
||||
'report_id' => $report->id,
|
||||
'report_version_no' => $report->version_no,
|
||||
'report_sha256' => $report->report_sha256,
|
||||
'report_snapshot' => $report->report_snapshot,
|
||||
'consented_at' => now()->toISOString(),
|
||||
];
|
||||
$attached = $drafts->attachSimulation($id, $sourceSnapshot, $request);
|
||||
$draft = $attached['draft'];
|
||||
|
||||
return response()->json([
|
||||
'data' => $drafts->present($draft, $request->user() !== null),
|
||||
'replayed' => $attached['replayed'],
|
||||
'expires_at' => $draft->expires_at?->toISOString(),
|
||||
])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ResolveSimulationTaxParametersRequest;
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SimulationTaxParameterController extends Controller
|
||||
{
|
||||
public function resolve(ResolveSimulationTaxParametersRequest $request, SimulationTaxParameterResolver $resolver): JsonResponse
|
||||
{
|
||||
$input = $request->validated();
|
||||
$data = $resolver->resolvePublishedForPeriod(
|
||||
$input['component'],
|
||||
$input['period_start'],
|
||||
$input['period_end'],
|
||||
$input['context'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'component' => $input['component'],
|
||||
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
|
||||
'assumptions' => $input['context'] ?? [],
|
||||
'parameters' => $data,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DocumentMalwareController extends Controller
|
||||
{
|
||||
public function retry(Request $request, string $id): RedirectResponse
|
||||
{
|
||||
DB::transaction(function () use ($id): void {
|
||||
$document = DB::table('documents')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
|
||||
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
|
||||
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
|
||||
|
||||
DB::table('documents')->where('id', $id)->update([
|
||||
'status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now(),
|
||||
]);
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Models\Skill;
|
||||
use App\Models\TrainingCapacity;
|
||||
use App\Models\TrainingProvider;
|
||||
use App\Services\EducationGapCalculator;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -28,17 +29,22 @@ class EducationController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
|
||||
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
'demand_page' => ['nullable', 'integer', 'min:1'],
|
||||
'capacity_page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
$demands = LabourDemand::query();
|
||||
$capacities = TrainingCapacity::query();
|
||||
$territoryIds = [];
|
||||
foreach ([$demands, $capacities] as $query) {
|
||||
if ($term = trim($filters['q'] ?? '')) {
|
||||
$query->whereLike('qualification', '%'.addcslashes($term, '%_\\').'%');
|
||||
}
|
||||
if ($territory = $filters['geo_location_id'] ?? null) {
|
||||
$query->where('geo_location_id', $territory);
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
$territory = $filters['geo_location_id'] ?? ($path !== [] ? $path[array_key_last($path)]->id : null);
|
||||
if ($territory) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($territory, includeNonPublic: true);
|
||||
$query->whereIn('geo_location_id', $territoryIds);
|
||||
}
|
||||
if (($filters['status'] ?? null) === 'expired') {
|
||||
$query->where('valid_until', '<', now()->toDateString());
|
||||
@@ -47,6 +53,17 @@ class EducationController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$territorialCapacities = clone $capacities;
|
||||
$providers = TrainingProvider::where('status', '!=', 'inactive');
|
||||
$courses = Course::where('status', '!=', 'retired')
|
||||
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
|
||||
if ($territoryIds !== []) {
|
||||
// Provider and course location is evidenced by their submitted training capacity.
|
||||
// Do not infer locations from provider names, addresses, or unrelated records.
|
||||
$courses->whereIn('id', $territorialCapacities->select('course_id'));
|
||||
$providers->whereIn('id', (clone $courses)->select('provider_id'));
|
||||
}
|
||||
|
||||
return Inertia::render('education/index', [
|
||||
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
|
||||
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
|
||||
@@ -59,8 +76,8 @@ class EducationController extends Controller
|
||||
],
|
||||
'skills' => Skill::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'occupations' => Occupation::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'providers' => TrainingProvider::where('status', '!=', 'inactive')->orderBy('name')->get(['id', 'name']),
|
||||
'courses' => Course::where('status', '!=', 'retired')->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->orderBy('title')->get(['id', 'title', 'provider_id']),
|
||||
'providers' => $providers->orderBy('name')->get(['id', 'name']),
|
||||
'courses' => $courses->orderBy('title')->get(['id', 'title', 'provider_id']),
|
||||
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'territories' => DB::table('geo_locations')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
|
||||
@@ -98,7 +115,8 @@ class EducationController extends Controller
|
||||
$data = $request->validate([
|
||||
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
|
||||
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
|
||||
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
|
||||
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
|
||||
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
|
||||
? Rule::exists('projects', 'id')->whereNull('deleted_at')
|
||||
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LogicException;
|
||||
|
||||
class EducationInvestmentController extends Controller
|
||||
{
|
||||
@@ -44,9 +45,13 @@ class EducationInvestmentController extends Controller
|
||||
return;
|
||||
}
|
||||
$territory = app(InvestmentGeography::class)->selection($data, true);
|
||||
$lastTerritoryKey = array_key_last($territory);
|
||||
if ($lastTerritoryKey === null) {
|
||||
throw new LogicException('A proposta educativa exige uma localização territorial.');
|
||||
}
|
||||
$province = $territory[0];
|
||||
$district = $territory[1] ?? null;
|
||||
$primaryTerritory = $territory[array_key_last($territory)];
|
||||
$primaryTerritory = $territory[$lastTerritoryKey];
|
||||
$reference = 'EDU-'.Str::uuid();
|
||||
$opportunity = new Opportunity;
|
||||
PortalAudit::save($opportunity, [
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\ExistingBusinessDrafts;
|
||||
use App\Services\ExistingBusinessIntake;
|
||||
use App\Services\ExistingBusinessObjects;
|
||||
use App\Services\ExistingBusinessVerification;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use App\Services\Mne\CompatibilityShadow;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -100,6 +101,7 @@ class ExistingBusinessController extends Controller
|
||||
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
|
||||
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
|
||||
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
|
||||
]), 'payload' => $business->payload];
|
||||
]), 'payload' => $business->payload,
|
||||
'territory_paths' => app(InvestmentGeography::class)->displayPaths($business->payload)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class InvestmentDiscoveryController extends Controller
|
||||
$filters['education'] = true;
|
||||
}
|
||||
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(),
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
|
||||
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
|
||||
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
|
||||
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
|
||||
@@ -41,11 +41,7 @@ class InvestmentDiscoveryController extends Controller
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->where(function ($query) use ($ids) {
|
||||
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
|
||||
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
|
||||
}
|
||||
});
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $ids);
|
||||
}
|
||||
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
|
||||
return $query->whereRaw('false')->get();
|
||||
@@ -92,7 +88,11 @@ class InvestmentDiscoveryController extends Controller
|
||||
abort_unless($origin !== null, 404);
|
||||
}
|
||||
|
||||
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin]);
|
||||
$returnFilters = $request->query();
|
||||
unset($returnFilters['zone_id']);
|
||||
|
||||
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
|
||||
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
|
||||
}
|
||||
|
||||
public function detailApi(string $slug, InvestmentCatalogue $catalogue): JsonResponse
|
||||
|
||||
@@ -43,6 +43,8 @@ class InvestmentLegalSourceController extends Controller
|
||||
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
|
||||
if ($action === 'validate') {
|
||||
abort_if(str_contains(mb_strtolower($source->article), 'pending') || str_contains(mb_strtolower($source->article), 'pendente')
|
||||
|| str_contains(mb_strtolower($source->article), 'a rever'), 422, 'Indique o artigo e confirme o texto oficial antes da validação.');
|
||||
abort_if((int) $source->owner_id === $request->user()->id, 403, 'Outra pessoa deve validar a fonte.');
|
||||
abort_unless($source->validation_status === 'pending', 422, 'Crie nova referência para substituir fonte revogada.');
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Project;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -19,7 +23,7 @@ class InvestmentPortfolioController extends Controller
|
||||
{
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
|
||||
if ($a->$left && $b->$right && DB::table($table)->where($left, $a->$left)->where($right, $b->$right)->whereNull('deleted_at')->exists()) {
|
||||
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +32,13 @@ class InvestmentPortfolioController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function currentRelation(QueryBuilder $query): QueryBuilder
|
||||
{
|
||||
return $query->whereNull('deleted_at')->where('validation_status', 'validated')
|
||||
->whereDate('valid_from', '<=', today()->toDateString())
|
||||
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()->toDateString()));
|
||||
}
|
||||
|
||||
/** @return Builder<InvestmentDossier> */
|
||||
public static function visible(): Builder
|
||||
{
|
||||
@@ -42,7 +53,8 @@ class InvestmentPortfolioController extends Controller
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$management = $request->routeIs('management.portfolio*');
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project']]);
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$query = $management ? InvestmentDossier::query() : self::visible();
|
||||
if (! empty($filters['kind'])) {
|
||||
$query->whereNotNull($filters['kind'].'_id');
|
||||
@@ -50,9 +62,14 @@ class InvestmentPortfolioController extends Controller
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('title', 'ilike', '%'.$filters['q'].'%');
|
||||
}
|
||||
$territorySelection = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($territorySelection !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($territorySelection[array_key_last($territorySelection)]->id);
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
|
||||
}
|
||||
|
||||
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
|
||||
'management' => $management, 'filters' => $filters]);
|
||||
'management' => $management, 'filters' => $filters, 'territorySelection' => $territorySelection]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id): Response
|
||||
@@ -63,8 +80,8 @@ class InvestmentPortfolioController extends Controller
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
|
||||
if ($record->$origin) {
|
||||
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], DB::table($table)
|
||||
->where($origin, $record->$origin)->whereNull('deleted_at')->pluck($destination)->all());
|
||||
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], self::currentRelation(DB::table($table)
|
||||
->where($origin, $record->$origin))->pluck($destination)->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +115,16 @@ class InvestmentPortfolioController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$anchorProject = $record->project_id
|
||||
? Project::whereKey($record->project_id)->whereNull('deleted_at')->where('status', '!=', 'draft')->first(['id', 'code', 'name', 'status', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])
|
||||
: null;
|
||||
$existingSubmissions = ! $management && $request->user()
|
||||
? InvestmentSubmission::where('user_id', $request->user()->id)->where('dossier_id', $record->id)->where('status', 'draft')
|
||||
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
|
||||
: collect();
|
||||
|
||||
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
||||
}
|
||||
|
||||
@@ -114,7 +140,7 @@ class InvestmentPortfolioController extends Controller
|
||||
'publication_status' => 'draft', 'published_at' => null, 'approval_evidence' => null, 'approval_basis' => 'manager'], $request->user()->id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Rascunho guardado. Pode aprovar e publicar a ficha.');
|
||||
return back()->with('success', 'Rascunho guardado. A publicacao aguarda um revisor diferente do editor.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
|
||||
@@ -123,6 +149,8 @@ class InvestmentPortfolioController extends Controller
|
||||
DB::transaction(function () use ($request, $dossier, $data) {
|
||||
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
||||
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de publicar.');
|
||||
abort_unless($dossier->editor_id !== null, 422, 'A ficha deve ser assumida e revista antes da publicacao.');
|
||||
abort_if((int) $dossier->editor_id === (int) $request->user()->id, 403, 'A publicacao exige um revisor diferente do editor.');
|
||||
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'approval_basis' => 'manager', 'reviewer_id' => $request->user()->id, 'publication_status' => 'published', 'published_at' => now()], $request->user()->id);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ClusterProject;
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentGap;
|
||||
use App\Models\Project;
|
||||
@@ -21,7 +22,7 @@ class InvestmentStructureController extends Controller
|
||||
public function index(Request $request, string $structure): Response
|
||||
{
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
|
||||
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
|
||||
$model = $this->model($structure);
|
||||
$query = $model::query();
|
||||
if ($term = $filters['q'] ?? null) {
|
||||
$query->where(function ($q) use ($term): void {
|
||||
@@ -33,9 +34,9 @@ class InvestmentStructureController extends Controller
|
||||
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
|
||||
}
|
||||
|
||||
public function edit(string $structure, ?string $id = null): Response
|
||||
public function edit(Request $request, string $structure, ?string $id = null): Response
|
||||
{
|
||||
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
|
||||
$model = $this->model($structure);
|
||||
$record = $id ? $model::findOrFail($id) : null;
|
||||
|
||||
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
|
||||
@@ -49,30 +50,36 @@ class InvestmentStructureController extends Controller
|
||||
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
|
||||
->orderBy('origins.code')->orderBy('destinations.code')
|
||||
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
|
||||
'viewerId' => $request->user()->id,
|
||||
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
|
||||
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
|
||||
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
|
||||
->orderBy('projects.name')->get(['cluster_projects.id', 'project_id', 'role', 'projects.name']) : [],
|
||||
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request, string $structure, ?string $id = null): RedirectResponse
|
||||
{
|
||||
$isProject = $structure === 'projects';
|
||||
$model = $isProject ? Project::class : EconomicCluster::class;
|
||||
$isCluster = $structure === 'clusters';
|
||||
$isCorridor = $structure === 'corridors';
|
||||
$model = $this->model($structure);
|
||||
$record = $id ? $model::findOrFail($id) : new $model;
|
||||
$rules = [
|
||||
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ['planned', 'active', 'inactive'])],
|
||||
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
|
||||
];
|
||||
if ($isProject || $isCorridor) {
|
||||
$rules['description'] = ['nullable', 'string', 'max:10000'];
|
||||
}
|
||||
if ($isProject) {
|
||||
$rules += ['description' => ['nullable', 'string', 'max:10000'],
|
||||
$rules += [
|
||||
'budget_amount' => ['nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'],
|
||||
'budget_currency' => ['nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'],
|
||||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', ...($request->filled('start_date') ? ['after_or_equal:start_date'] : [])]];
|
||||
} else {
|
||||
} elseif ($isCluster) {
|
||||
$rules['sector'] = ['required', 'string', 'max:200'];
|
||||
}
|
||||
if (! $id) {
|
||||
@@ -92,14 +99,52 @@ class InvestmentStructureController extends Controller
|
||||
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
|
||||
}
|
||||
|
||||
/** @return class-string<Project|EconomicCluster|DevelopmentCorridor> */
|
||||
private function model(string $structure): string
|
||||
{
|
||||
return match ($structure) {
|
||||
'projects' => Project::class,
|
||||
'clusters' => EconomicCluster::class,
|
||||
'corridors' => DevelopmentCorridor::class,
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]]);
|
||||
'version_no' => ['required', 'integer', 'min:0'],
|
||||
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
|
||||
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
|
||||
DB::transaction(function () use ($cluster, $data, $request) {
|
||||
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
|
||||
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->first() ?? new ClusterProject;
|
||||
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
|
||||
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
if ($link) {
|
||||
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
|
||||
$changed = false;
|
||||
foreach (['role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until'] as $field) {
|
||||
$current = $link->getAttribute($field);
|
||||
$current = $current instanceof \DateTimeInterface ? $current->format('Y-m-d') : $current;
|
||||
if (($current ?: null) !== (($data[$field] ?? null) ?: null)) {
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $changed) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
abort_if((int) $data['version_no'] !== 0, 409, 'A associacao foi alterada. Actualize a pagina.');
|
||||
$link = new ClusterProject;
|
||||
}
|
||||
unset($data['version_no']);
|
||||
$nextVersion = $link->exists ? ((int) $link->version_no + 1) : 1;
|
||||
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id, 'validation_status' => 'pending',
|
||||
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => $nextVersion], $request->user()->id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Associação ao projecto guardada.');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvestmentStructureRelationController extends Controller
|
||||
{
|
||||
private const TABLES = [
|
||||
'cluster-project' => ['cluster_projects', 'cluster_id', 'project_id', 'economic_clusters', 'projects'],
|
||||
'corridor-project' => ['corridor_projects', 'corridor_id', 'project_id', 'development_corridors', 'projects'],
|
||||
'corridor-cluster' => ['corridor_clusters', 'corridor_id', 'cluster_id', 'development_corridors', 'economic_clusters'],
|
||||
];
|
||||
|
||||
public function store(Request $request, string $kind): RedirectResponse
|
||||
{
|
||||
[$table, $left, $right, $leftTable, $rightTable] = $this->definition($kind);
|
||||
$data = $request->validate([
|
||||
$left => ['required', 'uuid', Rule::exists($leftTable, 'id')->whereNull('deleted_at')],
|
||||
$right => ['required', 'uuid', Rule::exists($rightTable, 'id')->whereNull('deleted_at')],
|
||||
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
|
||||
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
|
||||
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_if($existing !== null, 409, 'Associacao ja registada.');
|
||||
$values = [...$data, 'association_type' => $data['association_type'], 'validation_status' => 'pending',
|
||||
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1,
|
||||
'deleted_at' => null, 'updated_at' => now()];
|
||||
$id = (string) Str::uuid();
|
||||
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
|
||||
$this->audit($request, $table, $id, 'submitted', 1);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Associacao submetida para revisao independente.');
|
||||
}
|
||||
|
||||
public function review(Request $request, string $kind, string $id, string $action): RedirectResponse
|
||||
{
|
||||
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
|
||||
[$table] = $this->definition($kind);
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
|
||||
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($relation !== null, 404);
|
||||
abort_if((int) $relation->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
|
||||
if ($action === 'validate') {
|
||||
abort_if((int) $relation->editor_id === (int) $request->user()->id, 403, 'A associacao exige revisao independente.');
|
||||
abort_unless($relation->validation_status === 'pending' && $relation->source_reference && $relation->evidence_reference && $relation->valid_from, 422, 'Fonte, evidencia ou vigencia em falta.');
|
||||
}
|
||||
$nextVersion = (int) $relation->version_no + 1;
|
||||
DB::table($table)->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
|
||||
'validator_id' => $request->user()->id, 'version_no' => $nextVersion, 'updated_at' => now()]);
|
||||
$this->audit($request, $table, $id, $action, $nextVersion);
|
||||
});
|
||||
|
||||
return back()->with('success', $action === 'validate' ? 'Associacao validada.' : 'Associacao revogada.');
|
||||
}
|
||||
|
||||
/** @return array{string,string,string,string,string} */
|
||||
private function definition(string $kind): array
|
||||
{
|
||||
abort_unless(isset(self::TABLES[$kind]), 404);
|
||||
|
||||
return self::TABLES[$kind];
|
||||
}
|
||||
|
||||
private function audit(Request $request, string $table, string $id, string $action, int $version): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-structure-relations', 'entity_type' => $table,
|
||||
'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action,
|
||||
'after_data' => json_encode(['version_no' => $version, 'validation_status' => $action === 'validate' ? 'validated' : null], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\InvestmentSpecificNeed;
|
||||
use App\Models\InvestmentSubmission;
|
||||
@@ -293,7 +294,8 @@ class InvestmentSubmissionController extends Controller
|
||||
abort_unless($key !== false, 500);
|
||||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)),
|
||||
'status' => 'active', 'data_classification' => 'confidential']);
|
||||
'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'confidential']);
|
||||
DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$row->increment('version');
|
||||
$this->event($row, $request, 'attachment_added');
|
||||
@@ -307,6 +309,8 @@ class InvestmentSubmissionController extends Controller
|
||||
throw $e;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
|
||||
}
|
||||
|
||||
@@ -314,13 +318,16 @@ class InvestmentSubmissionController extends Controller
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')->first(['d.file_name', 'd.storage_key', 'd.sha256']);
|
||||
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']);
|
||||
abort_unless($doc !== null, 404);
|
||||
$root = realpath(Storage::disk('local')->path('investment-submissions/'.$id));
|
||||
$path = realpath(Storage::disk('local')->path($doc->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$hash = hash_file('sha256', $path);
|
||||
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash), 404);
|
||||
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash)
|
||||
&& hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404);
|
||||
|
||||
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InvestmentZone;
|
||||
use App\Services\InvestmentZoneAccess;
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class InvestmentZoneAttachmentController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
public function store(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$zone = InvestmentZone::whereKey($id)->firstOrFail();
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'file', 'mimetypes:application/pdf,image/jpeg,image/png,image/webp', 'max:20480'],
|
||||
'kind' => ['required', 'in:legal_instrument,map,photo,study,other'],
|
||||
'caption' => ['required', 'string', 'max:255'],
|
||||
'source_reference' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
'source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
|
||||
'rights_basis' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
$file = $request->file('file');
|
||||
abort_unless(in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true), 422, 'Formato de ficheiro não permitido.');
|
||||
$this->validateFileContent($file->getRealPath(), $file->getMimeType());
|
||||
abort_if(DB::table('investment_zone_attachments')->where('zone_id', $id)->count() >= 20, 422, 'Máximo de vinte anexos por zona.');
|
||||
|
||||
$attachmentId = (string) Str::uuid();
|
||||
$documentId = (string) Str::uuid();
|
||||
$key = null;
|
||||
try {
|
||||
$key = $file->storeAs('investment-zones/'.$id, $documentId, 'local');
|
||||
abort_unless($key !== false, 500, 'Não foi possível guardar o ficheiro.');
|
||||
$hash = hash_file('sha256', Storage::disk('local')->path($key));
|
||||
abort_unless(is_string($hash), 500, 'Não foi possível verificar o ficheiro guardado.');
|
||||
|
||||
$attachment = DB::transaction(function () use ($request, $zone, $data, $file, $attachmentId, $documentId, $key, $hash): object {
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId, 'source_system' => 'investment-zones', 'version_no' => 1,
|
||||
'data_classification' => 'confidential', 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash,
|
||||
// Follow the portal's existing file policy: no antivirus engine is configured, so uploads stay quarantined.
|
||||
'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('investment_zone_attachments')->insert([
|
||||
'id' => $attachmentId, 'zone_id' => $zone->id, 'document_id' => $documentId,
|
||||
'kind' => $data['kind'], 'caption' => $data['caption'], 'source_reference' => $data['source_reference'],
|
||||
'source_date' => $data['source_date'] ?? null, 'rights_status' => $data['rights_status'],
|
||||
'rights_basis' => $data['rights_basis'] ?? null, 'personal_data_status' => 'unknown',
|
||||
'review_status' => 'pending', 'uploaded_by' => $request->user()->id, 'version_no' => 1,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$this->event($attachmentId, $request->user()->id, 'uploaded', 1, ['kind' => $data['kind'], 'sha256' => $hash]);
|
||||
|
||||
return DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $attachmentId)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
if (is_string($key)) {
|
||||
Storage::disk('local')->delete($key);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return response()->json($attachment, 201);
|
||||
}
|
||||
|
||||
public function review(Request $request, string $id, string $action): JsonResponse
|
||||
{
|
||||
abort_unless(in_array($action, ['review', 'select-public', 'withdraw-public'], true), 404);
|
||||
$rules = ['version_no' => ['required', 'integer', 'min:1']];
|
||||
if ($action === 'review') {
|
||||
$rules += ['decision' => ['required', 'in:reviewed,rejected'],
|
||||
'personal_data_status' => ['required', 'in:none,present,unknown'],
|
||||
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
|
||||
'rights_basis' => ['nullable', 'string', 'max:2000']];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
if ($action === 'select-public' || $action === 'withdraw-public') {
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse {
|
||||
$attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($attachment, 404);
|
||||
abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.');
|
||||
$version = (int) $attachment->version_no + 1;
|
||||
$changes = ['version_no' => $version, 'updated_at' => now()];
|
||||
$metadata = [];
|
||||
|
||||
if ($action === 'review') {
|
||||
abort_if($data['rights_status'] === 'confirmed' && trim((string) ($data['rights_basis'] ?? $attachment->rights_basis)) === '', 422, 'Registe o fundamento dos direitos antes de os confirmar.');
|
||||
$changes += [
|
||||
'review_status' => $data['decision'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(),
|
||||
'independent_review' => (int) $attachment->uploaded_by !== (int) $request->user()->id,
|
||||
'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status'],
|
||||
'rights_basis' => $data['rights_basis'] ?? $attachment->rights_basis,
|
||||
'publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null,
|
||||
];
|
||||
$metadata = ['decision' => $data['decision'], 'independent_review' => $changes['independent_review'], 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status']];
|
||||
} elseif ($action === 'select-public') {
|
||||
abort_unless($attachment->review_status === 'reviewed' && $attachment->rights_status === 'confirmed'
|
||||
&& trim((string) $attachment->rights_basis) !== '' && $attachment->personal_data_status === 'none', 422, 'O anexo precisa de revisão registada, fundamento dos direitos, confirmação de divulgação e verificação de ausência de dados pessoais.');
|
||||
abort_unless(DB::table('documents')->where('id', $attachment->document_id)->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')->exists(), 422,
|
||||
'A análise antimalware tem de terminar sem ameaça antes da divulgação.');
|
||||
abort_unless(app(InvestmentZones::class)->visible()->whereKey($attachment->zone_id)->exists(), 422, 'A zona deve estar publicada antes da divulgação do anexo.');
|
||||
$changes += ['publicly_selected' => true, 'publicly_selected_at' => now(), 'publicly_selected_by' => $request->user()->id];
|
||||
} else {
|
||||
$changes += ['publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null];
|
||||
}
|
||||
|
||||
DB::table('investment_zone_attachments')->where('id', $id)->update($changes);
|
||||
$this->event($id, $request->user()->id, $action, $version, $metadata);
|
||||
|
||||
return response()->json(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']));
|
||||
});
|
||||
}
|
||||
|
||||
public function downloadPrivate(string $zoneId, string $id): BinaryFileResponse
|
||||
{
|
||||
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
|
||||
abort_unless($record, 404);
|
||||
|
||||
return $this->verifiedDownload($zoneId, $record);
|
||||
}
|
||||
|
||||
public function downloadPublic(string $zoneId, string $id): BinaryFileResponse
|
||||
{
|
||||
abort_unless(app(InvestmentZones::class)->visible()->whereKey($zoneId)->exists(), 404);
|
||||
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('a.publicly_selected', true)
|
||||
->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')
|
||||
->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')
|
||||
->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
|
||||
abort_unless($record, 404);
|
||||
|
||||
$extension = match ($record->media_type) {
|
||||
'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin'
|
||||
};
|
||||
|
||||
return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension);
|
||||
}
|
||||
|
||||
private function verifiedDownload(string $zoneId, object $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
|
||||
{
|
||||
$root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$hash = hash_file('sha256', $path);
|
||||
abort_unless(is_string($hash) && hash_equals(trim($document->sha256), $hash)
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
|
||||
|
||||
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
|
||||
$safeName = $downloadName ?? basename($document->file_name);
|
||||
|
||||
return $inline
|
||||
? response()->file($path, $headers + ['Content-Type' => $document->media_type, 'Content-Disposition' => 'inline; filename="'.$safeName.'"'])
|
||||
: response()->download($path, $safeName, $headers);
|
||||
}
|
||||
|
||||
private function validateFileContent(string $path, string $mime): void
|
||||
{
|
||||
if ($mime === 'application/pdf') {
|
||||
$handle = fopen($path, 'rb');
|
||||
$signature = $handle ? fread($handle, 5) : false;
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
abort_unless($signature === '%PDF-', 422, 'O conteúdo não corresponde a um PDF válido.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$image = @getimagesize($path);
|
||||
abort_unless($image && $image[0] > 0 && $image[1] > 0 && $image[0] * $image[1] <= 40000000, 422, 'A imagem é inválida ou excede 40 megapíxeis.');
|
||||
$expected = ['image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP][$mime] ?? null;
|
||||
abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.');
|
||||
}
|
||||
|
||||
private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void
|
||||
{
|
||||
DB::table('investment_zone_attachment_events')->insert([
|
||||
'id' => (string) Str::uuid(), 'attachment_id' => $attachmentId, 'actor_id' => $actor,
|
||||
'action' => $action, 'version_no' => $version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ use App\Services\InvestmentZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -22,7 +24,14 @@ class InvestmentZoneController extends Controller
|
||||
{
|
||||
$management = $request->is('management/*', 'api/v2/investment/admin/*');
|
||||
$zone = ($management ? InvestmentZone::query() : $zones->visible())->findOrFail($id);
|
||||
$publicFields = ['id', 'code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'validation_status', 'updated_at'];
|
||||
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
|
||||
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
|
||||
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
|
||||
$zone->setAttribute('territory_path', $territoryPath);
|
||||
$publicFields = ['id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at'];
|
||||
$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']);
|
||||
$operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name');
|
||||
@@ -33,7 +42,26 @@ class InvestmentZoneController extends Controller
|
||||
$relations = $zones->links(true)->where('zl.zone_id', $id)->whereNotNull('zl.'.$foreign);
|
||||
$related = $related->merge(InvestmentPortfolioController::visible()->whereIn($foreign, $relations->select('zl.'.$foreign))->get());
|
||||
}
|
||||
$attachmentColumns = $management
|
||||
? ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'a.rights_status', 'a.rights_basis', 'a.personal_data_status',
|
||||
'a.review_status', 'a.reviewed_by', 'a.reviewed_at', 'a.independent_review', 'a.publicly_selected', 'a.publicly_selected_by',
|
||||
'a.version_no', 'a.uploaded_by', 'a.created_at', 'd.id as document_id', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.status as file_status',
|
||||
'd.malware_scan_status', 'd.malware_failure_reason', 'd.malware_scanned_at']
|
||||
: ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'd.media_type', 'd.size_bytes'];
|
||||
$attachments = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.zone_id', $id)->when(! $management, fn ($query) => $query->where('d.status', 'active'))
|
||||
->when(! $management, fn ($query) => $query->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256'))
|
||||
->whereNull('d.deleted_at')
|
||||
->when(! $management, fn ($query) => $query->where('a.publicly_selected', true)->where('a.review_status', 'reviewed')
|
||||
->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none'))
|
||||
->orderBy('a.created_at')->get($attachmentColumns);
|
||||
$attachments->each(function ($attachment) use ($id, $management): void {
|
||||
$attachment->download_url = $management
|
||||
? url('/management/investment/zones/'.$id.'/attachments/'.$attachment->id)
|
||||
: url('/zonas/'.$id.'/anexos/'.$attachment->id);
|
||||
});
|
||||
$props = ['record' => $management ? $zone : $zone->only($publicFields), 'source' => $source, 'operator' => $operator, 'management' => $management,
|
||||
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
|
||||
'related' => $related->unique('id')->values(),
|
||||
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'geometries' => $management ? DB::table('investment_zone_geometries')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
@@ -43,6 +71,8 @@ class InvestmentZoneController extends Controller
|
||||
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
|
||||
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'locations' => $locations,
|
||||
'attachments' => $attachments,
|
||||
'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []];
|
||||
|
||||
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zone', $props);
|
||||
@@ -50,31 +80,132 @@ class InvestmentZoneController extends Controller
|
||||
|
||||
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
|
||||
{
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$management = $request->is('management/*', 'api/v2/investment/admin/*');
|
||||
$query = $management ? InvestmentZone::query() : $zones->visible();
|
||||
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
|
||||
}
|
||||
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
|
||||
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
|
||||
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
|
||||
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
|
||||
$publicColumns = ['id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
|
||||
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
|
||||
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
|
||||
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
|
||||
$rows->getCollection()->transform(function ($zone) use ($management) {
|
||||
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
|
||||
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
|
||||
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
|
||||
$zone->setAttribute('territory_path', $territoryPath);
|
||||
$zone->setAttribute('locations', DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
|
||||
->where('zl.zone_id', $zone->id)->whereNull('g.deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
|
||||
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
|
||||
|
||||
return $zone;
|
||||
});
|
||||
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
|
||||
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
|
||||
|
||||
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
|
||||
}
|
||||
|
||||
public function export(Request $request, InvestmentZones $zones)
|
||||
{
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$query = $zones->search($filters)->orderBy('name')->orderBy('id');
|
||||
|
||||
return response()->streamDownload(function () use ($query): void {
|
||||
$geography = app(InvestmentGeography::class);
|
||||
$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 {
|
||||
foreach ($zones as $zone) {
|
||||
$territoryPath = $geography->path($zone->primary_territory_id);
|
||||
$province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province;
|
||||
$district = collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district;
|
||||
$sources = collect($zone->source_evidence ?? [])->map(fn ($source) => ($source['title'] ?? '').' — '.($source['url'] ?? '').' — '.($source['source_date'] ?? 'data não informada'))->implode(' | ');
|
||||
$values = [$zone->code, $zone->name, $zone->physical_category, $zone->legal_regime, $zone->development_status,
|
||||
$zone->availability_status, $province, $district, $zone->area_value, $zone->area_unit, $sources];
|
||||
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r]/u', $value) ? "'".$value : $value, $values);
|
||||
fputcsv($stream, $values, ';');
|
||||
}
|
||||
});
|
||||
fclose($stream);
|
||||
}, 'zonas-e-parques.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse
|
||||
{
|
||||
if ($request->has('aliases_text')) {
|
||||
$request->merge(['aliases' => array_values(array_unique(array_filter(preg_split('/[\r\n,;]+/u', (string) $request->input('aliases_text')) ?: [], fn ($value) => trim($value) !== '')))]);
|
||||
}
|
||||
if ($request->filled('historical_name')) {
|
||||
$request->merge(['historical_territory' => [
|
||||
'name' => $request->input('historical_name'), 'district' => $request->input('historical_district'),
|
||||
'province' => $request->input('historical_province'), 'source_reference' => $request->input('historical_source_reference'),
|
||||
]]);
|
||||
}
|
||||
if ($request->filled('activity_name')) {
|
||||
$request->merge(['activities' => [['name' => $request->input('activity_name'), 'status' => $request->input('activity_status', 'potential')]]]);
|
||||
}
|
||||
$data = $request->validate(['version_no' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
|
||||
'code' => ['required', 'string', 'max:80', Rule::unique('investment_zones')->ignore($id)],
|
||||
'category' => ['required', 'in:zee,zfi,industrial_park,pending'], 'name' => ['required', 'string', 'max:255'],
|
||||
'name_en' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:15000'],
|
||||
'description_en' => ['nullable', 'string', 'max:15000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
|
||||
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
||||
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
|
||||
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id'],
|
||||
'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'],
|
||||
'operational_status' => ['nullable', 'in:operational,implementation,planned,suspended,unknown'],
|
||||
'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'],
|
||||
'area_components' => ['sometimes', 'array', 'max:30'], 'area_components.*' => ['array:type,value,unit,source_reference,source_date,status'],
|
||||
'area_components.*.type' => ['required', 'in:total,legal,developed,pilot,available'], 'area_components.*.value' => ['nullable', 'numeric', 'min:0'],
|
||||
'area_components.*.unit' => ['required', 'in:ha,km2,m2'], 'area_components.*.source_reference' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
'area_components.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_components.*.status' => ['required', 'in:reported,confirmed,disputed'],
|
||||
'source_evidence' => ['sometimes', 'array', 'max:30'], 'source_evidence.*' => ['array:title,url,source_type,supports,source_date'],
|
||||
'source_evidence.*.title' => ['required', 'string', 'max:255'], 'source_evidence.*.url' => ['required', 'url:https', 'max:2000'],
|
||||
'source_evidence.*.source_type' => ['required', 'in:government,institutional,promoter,other'], 'source_evidence.*.supports' => ['required', 'string', 'max:500'],
|
||||
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
|
||||
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'],
|
||||
'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:name,status,source_reference'],
|
||||
'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
|
||||
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'],
|
||||
'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:name,status,source_reference'],
|
||||
'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'],
|
||||
'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'],
|
||||
'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'],
|
||||
'historical_territory' => ['nullable', 'array:name,district,province,source_reference'],
|
||||
'historical_territory.name' => ['required_with:historical_territory', 'string', 'max:255'],
|
||||
'historical_territory.district' => ['nullable', 'string', 'max:120'], 'historical_territory.province' => ['nullable', 'string', 'max:120'],
|
||||
'historical_territory.source_reference' => ['nullable', 'string', 'max:2000'],
|
||||
'locations' => ['sometimes', 'array', 'max:20'], 'locations.*.geo_location_id' => ['required', 'uuid', 'exists:geo_locations,id'],
|
||||
'locations.*.scope_type' => ['required', 'in:legal_scope,administrative,site,influence'],
|
||||
'locations.*.source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'locations.*.legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
|
||||
|
||||
foreach (['development_status', 'availability_status', 'area_components', 'source_evidence'] as $column) {
|
||||
if (! Schema::hasColumn('investment_zones', $column)) {
|
||||
unset($data[$column]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['activities'] ?? [] as $index => $activity) {
|
||||
if (! in_array($activity['status'], ['potential', 'proposed'], true) && trim((string) ($activity['source_reference'] ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'activities.'.$index.'.source_reference' => 'Indique a fonte da actividade documentada antes de a classificar como autorizada, condicionada ou excluída.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $id && $request->has('province_id')) {
|
||||
$geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
@@ -105,4 +236,12 @@ class InvestmentZoneController extends Controller
|
||||
|
||||
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
|
||||
}
|
||||
|
||||
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
|
||||
{
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($zones->validateLocation($id, $data['version_no'], $request->user()->id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -98,9 +99,15 @@ class InvestmentZoneGeometryController extends Controller
|
||||
|
||||
public function map(Request $request, InvestmentZones $zones): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric']]);
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric'],
|
||||
'category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'string', 'max:40'], 'legal_regime' => ['nullable', 'string', 'max:30'],
|
||||
'development_status' => ['nullable', 'string', 'max:32'], 'availability_status' => ['nullable', 'string', 'max:32'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
abort_if((! empty($data['development_status']) || ! empty($data['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$eligibleZones = $zones->search($data)->select('investment_zones.id');
|
||||
$query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id')
|
||||
->whereIn('l.zone_id', $zones->visible()->select('investment_zones.id'))->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
||||
->whereIn('l.zone_id', $eligibleZones)->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
||||
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->when($data['zone_id'] ?? null, fn ($q, $v) => $q->where('l.zone_id', $v))
|
||||
->where(fn ($q) => $q->whereNull('l.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
|
||||
->whereColumn('ls.id', 'l.legal_source_id')->where('ls.validation_status', 'validated')->whereDate('ls.valid_from', '<=', today())
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Http\Requests\StoreLandAssistanceCaseRequest;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\LandAssistanceCase;
|
||||
use App\Services\InterestSubmission;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class LandAssistanceController extends Controller
|
||||
{
|
||||
private const DISCLAIMER = 'Este documento apoia a preparação e o acompanhamento. Não constitui requerimento oficialmente recebido, título de DUAT, confirmação de disponibilidade da terra ou decisão da autoridade competente.';
|
||||
|
||||
public function guide(): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/guide', ['disclaimer' => self::DISCLAIMER]);
|
||||
}
|
||||
|
||||
public function blank(): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/blank', ['disclaimer' => self::DISCLAIMER]);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$cases = LandAssistanceCase::where('user_id', $request->user()->id)
|
||||
->latest('updated_at')->paginate(20)->through(fn (LandAssistanceCase $case): array => [
|
||||
'id' => $case->id,
|
||||
'reference' => $case->reference,
|
||||
'status' => $case->status,
|
||||
'version' => $case->version,
|
||||
'updated_at' => $case->updated_at,
|
||||
]);
|
||||
|
||||
return Inertia::render('land-assistance/workspace', ['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/form', [
|
||||
'case' => null,
|
||||
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, string $id): Response
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido não está aberto para edição.');
|
||||
|
||||
return Inertia::render('land-assistance/form', [
|
||||
'case' => $case->only(['id', 'reference', 'version', 'status', 'payload']),
|
||||
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreLandAssistanceCaseRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$payload = $request->payload();
|
||||
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
|
||||
$payload['email'] = $payload['email'] ?? $request->user()->email;
|
||||
$case = DB::transaction(function () use ($request, $payload): LandAssistanceCase {
|
||||
$case = LandAssistanceCase::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'reference' => 'TERRA-'.now()->format('Y').'-'.Str::upper(Str::random(8)),
|
||||
'status' => 'draft',
|
||||
'version' => 1,
|
||||
'payload' => $payload,
|
||||
'crm_submission_key' => (string) Str::uuid(),
|
||||
]);
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'draft_created');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version], 201)
|
||||
: redirect()->route('land-assistance.show', $case->id);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id): Response|JsonResponse
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
$data = [
|
||||
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
|
||||
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')
|
||||
->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
|
||||
'events' => DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')
|
||||
->get(['action', 'version', 'metadata', 'created_at']),
|
||||
'supportTicket' => $case->interest_request_id
|
||||
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
|
||||
: null,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'management' => false,
|
||||
];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('land-assistance/case', $data);
|
||||
}
|
||||
|
||||
public function save(StoreLandAssistanceCaseRequest $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$payload = $request->payload();
|
||||
$case = DB::transaction(function () use ($request, $id, $data, $payload): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido está bloqueado para edição pelo requerente.');
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload['full_name'] = $payload['full_name'] ?? $case->payload['full_name'] ?? $request->user()->name;
|
||||
$payload['email'] = $payload['email'] ?? $case->payload['email'] ?? $request->user()->email;
|
||||
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'draft_updated');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version])
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Rascunho guardado.');
|
||||
}
|
||||
|
||||
public function requestSupport(StoreLandAssistanceCaseRequest $request, string $id, InterestSubmission $submission): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$submittedPayload = $request->payload();
|
||||
$case = DB::transaction(function () use ($request, $id, $data, $submission, $submittedPayload): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido já não pode ser submetido neste estado.');
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload = [...$case->payload, ...$submittedPayload];
|
||||
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
|
||||
if (! array_key_exists('email', $payload)) {
|
||||
$payload['email'] = $request->user()->email;
|
||||
}
|
||||
$payload['email'] = is_string($payload['email']) && trim($payload['email']) !== '' ? strtolower(trim($payload['email'])) : null;
|
||||
abort_if(empty($payload['email']) && empty($payload['phone']), 422, 'Indique pelo menos um contacto valido.');
|
||||
$message = trim(($payload['request_description'] ?? 'Pedido de apoio sobre Terra e DUAT.')."\nReferência interna: {$case->reference}. Tipo: ".($payload['support_kind'] ?? 'não indicado').'.');
|
||||
$interest = $submission->receive([
|
||||
'kind' => 'support', 'fullName' => $payload['full_name'], 'email' => $payload['email'],
|
||||
'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado',
|
||||
'sectorInterest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
|
||||
'privacyConsent' => true, 'portal_user_id' => $request->user()->id,
|
||||
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
|
||||
], $case->crm_submission_key, fn () => InterestRequest::create([
|
||||
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $payload['full_name'],
|
||||
'email' => $payload['email'], 'phone' => $payload['phone'] ?? null,
|
||||
'country' => $payload['country'] ?? 'Não indicado', 'investor_type' => 'individual',
|
||||
'sector_interest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
|
||||
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/terra-duat',
|
||||
'metadata' => ['kind' => 'support', 'portal_user_id' => $request->user()->id,
|
||||
'land_case_id' => $case->id, 'land_case_reference' => $case->reference],
|
||||
]));
|
||||
$case->forceFill([
|
||||
'payload' => $payload,
|
||||
'status' => 'support_requested',
|
||||
'support_requested_at' => now(),
|
||||
'version' => $case->version + 1,
|
||||
'interest_request_id' => $interest->id,
|
||||
])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'support_requested');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version], 202)
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Pedido de apoio registado no InvestMoz. Isto não é submissão oficial de DUAT.');
|
||||
}
|
||||
|
||||
public function reportUpdate(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'reported_status' => ['required', 'string', 'max:1000'],
|
||||
'official_reference' => ['nullable', 'string', 'max:160'],
|
||||
'receiving_entity' => ['nullable', 'string', 'max:200'],
|
||||
'reported_at' => ['nullable', 'date_format:Y-m-d'],
|
||||
]);
|
||||
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['support_requested', 'internal_review', 'waiting_applicant', 'referral_prepared'], true), 409);
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload = $case->payload;
|
||||
$updates = $payload['reported_updates'] ?? [];
|
||||
$updates[] = ['reported_status' => $data['reported_status'], 'official_reference' => $data['official_reference'] ?? null,
|
||||
'receiving_entity' => $data['receiving_entity'] ?? null, 'reported_at' => $data['reported_at'] ?? null, 'recorded_at' => now()->toIso8601String()];
|
||||
$payload['reported_updates'] = array_slice($updates, -20);
|
||||
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'applicant_update_recorded');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['version' => $case->version, 'reported_by_applicant' => true])
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Actualização registada como informação comunicada por si.');
|
||||
}
|
||||
|
||||
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'category' => ['required', Rule::in(['sketch', 'prior_right', 'exploration_plan', 'official_receipt', 'consultation_minutes', 'photo', 'other'])],
|
||||
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
|
||||
]);
|
||||
$case = $this->owned($request, $id, true);
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
abort_unless(! in_array($case->status, ['closed'], true), 409);
|
||||
abort_if(DB::table('land_assistance_documents')->where('case_id', $id)->count() >= 20, 422, 'Máximo de vinte documentos por pedido.');
|
||||
|
||||
$file = $request->file('file');
|
||||
$documentId = (string) Str::uuid();
|
||||
$key = 'land-assistance/'.$id.'/'.$documentId;
|
||||
$stored = $file->storeAs('land-assistance/'.$id, $documentId, 'local');
|
||||
if (! is_string($stored) || $stored === '') {
|
||||
throw ValidationException::withMessages(['file' => 'Não foi possível guardar o ficheiro em armazenamento privado.']);
|
||||
}
|
||||
try {
|
||||
DB::transaction(function () use ($request, $case, $data, $file, $documentId, $stored): void {
|
||||
$locked = $this->owned($request, $case->id, true);
|
||||
$this->checkVersion($locked, (int) $data['version']);
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId,
|
||||
'source_system' => 'land-assistance',
|
||||
'data_classification' => 'restricted',
|
||||
'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => (string) $file->getMimeType(),
|
||||
'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)),
|
||||
// No antivirus engine is configured in this project; fail closed until an operator scans it.
|
||||
'status' => 'quarantined',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('land_assistance_documents')->insert([
|
||||
'id' => (string) Str::uuid(), 'case_id' => $locked->id, 'document_id' => $documentId,
|
||||
'category' => $data['category'], 'created_at' => now(),
|
||||
]);
|
||||
$locked->forceFill(['version' => $locked->version + 1])->save();
|
||||
$this->revision($locked);
|
||||
$this->event($locked, $request, 'attachment_added', ['document_id' => $documentId, 'category' => $data['category']]);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('local')->delete($stored);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['uploaded' => true], 201)
|
||||
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, string $id, string $attachment): BinaryFileResponse
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at')
|
||||
->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256')
|
||||
->first(['document.file_name', 'document.storage_key', 'document.sha256', 'document.malware_scanned_sha256']);
|
||||
abort_if($document === null, 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum)
|
||||
&& hash_equals($document->sha256, (string) $document->malware_scanned_sha256), 404);
|
||||
|
||||
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
public function print(Request $request, string $id): Response
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
|
||||
return Inertia::render('land-assistance/print', [
|
||||
'case' => $case->only(['reference', 'version', 'payload', 'updated_at']),
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function manage(Request $request): Response
|
||||
{
|
||||
$cases = LandAssistanceCase::query()->latest('updated_at')->paginate(30)->through(fn (LandAssistanceCase $case): array => [
|
||||
'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status,
|
||||
'version' => $case->version, 'updated_at' => $case->updated_at,
|
||||
'applicant' => $case->payload['full_name'] ?? null,
|
||||
]);
|
||||
|
||||
return Inertia::render('land-assistance/management', ['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function manageShow(Request $request, string $id): Response
|
||||
{
|
||||
$case = LandAssistanceCase::findOrFail($id);
|
||||
$events = DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')->get(['action', 'version', 'metadata', 'created_at']);
|
||||
|
||||
return Inertia::render('land-assistance/case', [
|
||||
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
|
||||
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
|
||||
'events' => $events,
|
||||
'supportTicket' => $case->interest_request_id
|
||||
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
|
||||
: null,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'management' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'status' => ['required', Rule::in(['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'])],
|
||||
'evidence_reference' => ['nullable', 'string', 'max:300'],
|
||||
]);
|
||||
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
|
||||
$case = LandAssistanceCase::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$allowed = [
|
||||
'support_requested' => ['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'],
|
||||
'internal_review' => ['waiting_applicant', 'referral_prepared', 'closed'],
|
||||
'waiting_applicant' => ['internal_review', 'referral_prepared', 'closed'],
|
||||
'referral_prepared' => ['internal_review', 'waiting_applicant', 'closed'],
|
||||
];
|
||||
abort_unless(in_array($data['status'], $allowed[$case->status] ?? [], true), 409, 'Transição de estado não permitida.');
|
||||
if ($data['status'] === 'referral_prepared' && empty($data['evidence_reference'])) {
|
||||
throw ValidationException::withMessages(['evidence_reference' => 'Registe a referência interna do material preparado para encaminhamento.']);
|
||||
}
|
||||
$previousStatus = $case->status;
|
||||
$case->forceFill(['status' => $data['status'], 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'status_changed', [
|
||||
'from' => $previousStatus, 'to' => $data['status'],
|
||||
'evidence_reference' => $data['evidence_reference'] ?? null,
|
||||
'external_submission_confirmed' => false,
|
||||
]);
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['status' => $case->status, 'version' => $case->version])
|
||||
: redirect()->route('land-assistance.manage.show', $case->id)->with('status', 'Estado interno actualizado. Nenhuma recepção pública foi presumida.');
|
||||
}
|
||||
|
||||
private function owned(Request $request, string $id, bool $lock = false): LandAssistanceCase
|
||||
{
|
||||
$query = LandAssistanceCase::whereKey($id)->where('user_id', $request->user()->id);
|
||||
|
||||
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
|
||||
}
|
||||
|
||||
private function checkVersion(LandAssistanceCase $case, int $expected): void
|
||||
{
|
||||
abort_unless($case->version === $expected, 412, 'Este rascunho foi alterado. Recarregue antes de continuar.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $metadata */
|
||||
private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void
|
||||
{
|
||||
DB::table('land_assistance_events')->insert([
|
||||
'case_id' => $case->id, 'actor_id' => $request->user()->id,
|
||||
'action' => $action, 'version' => $case->version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function revision(LandAssistanceCase $case): void
|
||||
{
|
||||
DB::table('land_assistance_revisions')->insert([
|
||||
'case_id' => $case->id, 'version' => $case->version,
|
||||
'snapshot' => Crypt::encryptString(json_encode(['status' => $case->status, 'payload' => $case->payload], JSON_THROW_ON_ERROR)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -133,21 +133,41 @@ class PortalManagementController extends Controller
|
||||
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
DB::transaction(function () use ($request, $interest, $data) {
|
||||
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
|
||||
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->lockForUpdate()->first();
|
||||
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de partilha revogado.');
|
||||
}
|
||||
|
||||
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'contact_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de contacto revogado.');
|
||||
}
|
||||
|
||||
private function withdrawConsent(Request $request, InterestRequest $interest, string $reason, string $purpose): void
|
||||
{
|
||||
DB::transaction(function () use ($request, $interest, $reason, $purpose) {
|
||||
$lockedInterest = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
|
||||
$consent = DB::table('consents')->where('interest_request_id', $lockedInterest->id)
|
||||
->where('purpose', $purpose)->where('status', 'granted')->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if (! $consent) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
|
||||
$correlationId = (string) Str::uuid();
|
||||
DB::table('business_workflow_transitions')->insert([
|
||||
'id' => (string) Str::uuid(), 'interest_request_id' => $interest->id,
|
||||
'from_status' => $interest->status, 'to_status' => $interest->status,
|
||||
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
|
||||
'id' => (string) Str::uuid(), 'interest_request_id' => $lockedInterest->id,
|
||||
'from_status' => $lockedInterest->status, 'to_status' => $lockedInterest->status,
|
||||
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($reason),
|
||||
'correlation_id' => $correlationId,
|
||||
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
|
||||
'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
|
||||
'purpose' => $purpose], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
DB::table('outbox_events')->insertOrIgnore([
|
||||
@@ -156,14 +176,12 @@ class PortalManagementController extends Controller
|
||||
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
|
||||
'correlation_id' => $correlationId, 'status' => 'pending',
|
||||
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
|
||||
'interest_id' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
|
||||
'interest_id' => $lockedInterest->integration_id, 'purpose' => $purpose], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de partilha revogado.');
|
||||
}
|
||||
|
||||
/** @return Builder<User> */
|
||||
private function interestAssignees(): Builder
|
||||
{
|
||||
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
|
||||
@@ -181,9 +199,16 @@ class PortalManagementController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
|
||||
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
|
||||
'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
$query = $model::query();
|
||||
if ($resource === 'opportunities') {
|
||||
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
|
||||
if (isset($filters['geography_scope'])) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
}
|
||||
if ($term = trim($filters['q'] ?? '')) {
|
||||
$query->where(function ($query) use ($term, $resource) {
|
||||
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
|
||||
@@ -211,6 +236,9 @@ class PortalManagementController extends Controller
|
||||
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
|
||||
'filters' => $filters,
|
||||
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
|
||||
'geographyReviewCount' => $resource === 'opportunities'
|
||||
? Opportunity::where('geography_scope', 'unspecified')->count()
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +286,7 @@ class PortalManagementController extends Controller
|
||||
'is_featured' => ['required', 'boolean'],
|
||||
];
|
||||
if ($resource === 'opportunities') {
|
||||
$rules['geography_scope'] = ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])];
|
||||
foreach (InvestmentGeography::FIELDS as $field => $type) {
|
||||
$rules[$field] = ['nullable', 'uuid'];
|
||||
}
|
||||
@@ -285,8 +314,14 @@ class PortalManagementController extends Controller
|
||||
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
|
||||
}
|
||||
$path = app(InvestmentGeography::class)->selection($data, true);
|
||||
if (($data['geography_scope'] ?? null) === 'national' && $path !== []) {
|
||||
throw ValidationException::withMessages(['geography_scope' => 'O âmbito nacional não pode ter uma localização territorial específica.']);
|
||||
}
|
||||
$data = array_diff_key($data, InvestmentGeography::FIELDS);
|
||||
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
if ($path !== [] && empty($data['geography_scope'])) {
|
||||
$data['geography_scope'] = 'specific';
|
||||
}
|
||||
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
|
||||
$data['province'] = $path[0]->name ?? null;
|
||||
$data['district'] = $path[1]->name ?? null;
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Sector;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -20,21 +24,40 @@ class PortalServicesController extends Controller
|
||||
{
|
||||
public function home(): Response
|
||||
{
|
||||
return Inertia::render('PortalInvestidor', ['essentialResources' => $this->resources()->where('r.essential', true)
|
||||
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]);
|
||||
try {
|
||||
$essentialResources = $this->resources()->where('r.essential', true)
|
||||
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title']);
|
||||
} catch (QueryException $exception) {
|
||||
// A catalogue schema mismatch must not take down unrelated home content.
|
||||
// Keep the document list empty and retain the full exception in internal logs.
|
||||
report($exception);
|
||||
$essentialResources = collect();
|
||||
}
|
||||
|
||||
return Inertia::render('PortalInvestidor', ['essentialResources' => $essentialResources]);
|
||||
}
|
||||
|
||||
private function resources(): Builder
|
||||
{
|
||||
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.published', true)->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at');
|
||||
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
|
||||
}
|
||||
|
||||
public function page(Request $request): Response
|
||||
{
|
||||
$page = $request->route()->defaults['page'];
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100']]);
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
|
||||
'document_type' => ['nullable', 'string', 'max:40'], 'language' => ['nullable', 'string', 'max:12'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'legal_status' => ['nullable', 'string', 'max:24'],
|
||||
'source_confirmation' => ['nullable', 'string', 'max:24'], 'territory_id' => ['nullable', 'uuid'],
|
||||
'activity' => ['nullable', 'array', 'max:10'], 'activity.*' => ['string', 'max:180'],
|
||||
'stage' => ['nullable', 'array', 'max:10'], 'stage.*' => ['string', 'max:80'],
|
||||
'module' => ['nullable', 'array', 'max:10'], 'module.*' => ['string', 'max:80'],
|
||||
]);
|
||||
$props = ['section' => $page, 'filters' => $filters];
|
||||
if ($page === 'sectors') {
|
||||
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
|
||||
@@ -46,15 +69,67 @@ class PortalServicesController extends Controller
|
||||
}
|
||||
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
|
||||
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
|
||||
foreach (['category', 'topic'] as $field) {
|
||||
foreach (['category', 'topic', 'document_type', 'language', 'legal_status', 'source_confirmation'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$query->where('r.'.$field, $filters[$field]);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
|
||||
if (! empty($filters['issuer'])) {
|
||||
$query->where('d.issuer', 'ilike', '%'.$filters['issuer'].'%');
|
||||
}
|
||||
if (! empty($filters['territory_id'])) {
|
||||
$territoryIds = $this->territoryScopeIds($filters['territory_id']);
|
||||
$query->where(function (Builder $territorial) use ($territoryIds): void {
|
||||
$territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $territoryIds)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'territory')->whereIn('l.target_key', $territoryIds));
|
||||
});
|
||||
}
|
||||
foreach (['activity', 'stage', 'module'] as $dimension) {
|
||||
if (! empty($filters[$dimension])) {
|
||||
$query->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', $dimension)
|
||||
->whereIn('l.target_key', array_values($filters[$dimension])));
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$query->where(fn (Builder $search) => $search->where('r.title', 'ilike', $term)->orWhere('r.description', 'ilike', $term)
|
||||
->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)
|
||||
->orWhere('r.source_reference', 'ilike', $term)->orWhere('r.category', 'ilike', $term)->orWhere('r.topic', 'ilike', $term));
|
||||
}
|
||||
$pageResult = $query->orderByRaw("CASE WHEN r.document_type IN ('official_form','digital_adaptation','guidance_template') THEN 1 ELSE 0 END")
|
||||
->orderByDesc('r.essential')->orderBy('r.title')->select([
|
||||
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
|
||||
'r.applicable_from', 'r.applicable_until',
|
||||
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
|
||||
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
|
||||
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
|
||||
])->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']);
|
||||
$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;
|
||||
|
||||
return $resource;
|
||||
});
|
||||
$props['resources'] = $pageResult;
|
||||
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
|
||||
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
|
||||
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
|
||||
$props['legalStatuses'] = (clone $this->resources())->distinct()->orderBy('r.legal_status')->pluck('r.legal_status');
|
||||
$props['territories'] = DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']);
|
||||
foreach (['activities' => 'activity', 'stages' => 'stage', 'modules' => 'module'] as $key => $type) {
|
||||
$props[$key] = DB::table('portal_resource_links as l')->join('portal_resources as r', 'r.id', '=', 'l.resource_id')
|
||||
->join('documents as d', 'd.id', '=', 'r.document_id')->where('l.target_type', $type)->where('r.published', true)
|
||||
->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->distinct()->orderBy('l.target_key')->pluck('l.target_key');
|
||||
}
|
||||
$props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString();
|
||||
}
|
||||
if ($page === 'partnerships') {
|
||||
$query = DB::table('portal_partnerships')->where('status', 'published');
|
||||
@@ -77,6 +152,55 @@ class PortalServicesController extends Controller
|
||||
return Inertia::render('portal/services', $props);
|
||||
}
|
||||
|
||||
public function recommendations(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
|
||||
'stage' => ['nullable', 'string', 'max:80'], 'module' => ['nullable', 'string', 'max:80'],
|
||||
]);
|
||||
$module = $data['module'] ?? 'simulator';
|
||||
$query = $this->resources()->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'module')->where('l.target_key', $module));
|
||||
$matches = [['type' => 'module', 'key' => $module]];
|
||||
foreach (['activity_id' => 'activity', 'stage' => 'stage'] as $input => $type) {
|
||||
if (! empty($data[$input])) {
|
||||
$key = $data[$input];
|
||||
$query->where(function (Builder $contextual) use ($type, $key): void {
|
||||
$contextual->whereNotExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as general')
|
||||
->whereColumn('general.resource_id', 'r.id')->where('general.target_type', $type))
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as matching')
|
||||
->whereColumn('matching.resource_id', 'r.id')->where('matching.target_type', $type)->where('matching.target_key', $key));
|
||||
});
|
||||
$matches[] = ['type' => $type, 'key' => $data[$input]];
|
||||
}
|
||||
}
|
||||
if (! empty($data['province_id'])) {
|
||||
$ids = $this->territoryScopeIds($data['province_id']);
|
||||
$query->where(fn (Builder $territorial) => $territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $ids)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')->whereColumn('l.resource_id', 'r.id')
|
||||
->where('l.target_type', 'territory')->whereIn('l.target_key', $ids)));
|
||||
$matches = [...$matches, ...array_map(fn ($id) => ['type' => 'territory', 'key' => $id], $ids)];
|
||||
}
|
||||
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
|
||||
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'r.source_reference']);
|
||||
$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']);
|
||||
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.'),
|
||||
'applicability' => $matched?->applicability ?? 'support',
|
||||
'basis_reference' => $matched?->basis_reference,
|
||||
'documentary_evidence' => $evidence->map(fn ($link) => ['key' => $link->target_key,
|
||||
'label' => config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key),
|
||||
'citation' => $link->basis_reference, 'status' => 'citation_checked_application_pending'])->values()];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $resources]);
|
||||
}
|
||||
|
||||
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
@@ -133,49 +257,198 @@ class PortalServicesController extends Controller
|
||||
return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function manage(): Response
|
||||
public function showDocument(Request $request, int $id): Response
|
||||
{
|
||||
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
|
||||
$resource = $this->resources()->where('r.id', $id)->first([
|
||||
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type',
|
||||
'r.document_number', 'r.document_date', 'r.applicable_from', 'r.applicable_until', 'r.source_url', 'r.language', 'r.version_label',
|
||||
'r.territorial_scope', 'r.territory_id', 'r.source_confirmation', 'r.legal_status', 'r.source_reference',
|
||||
'r.essential', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
|
||||
]);
|
||||
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']);
|
||||
$territory = $resource->territory_id
|
||||
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name')
|
||||
: null;
|
||||
$resource->territory_name = $territory;
|
||||
$resource->view_url = in_array($resource->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true)
|
||||
? route('services.document.view', ['id' => $id]) : null;
|
||||
$back = $request->query('back');
|
||||
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
|
||||
? $back : '/documentos';
|
||||
|
||||
return Inertia::render('portal/document', [
|
||||
'resource' => $resource,
|
||||
'backUrl' => $backUrl,
|
||||
'downloadUrl' => route('services.download', ['id' => $id]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function viewDocument(int $id): BinaryFileResponse
|
||||
{
|
||||
$document = $this->resources()->where('r.id', $id)->first([
|
||||
'd.storage_key', 'd.file_name', 'd.sha256', 'd.media_type',
|
||||
]);
|
||||
abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless($checksum !== false && hash_equals($document->sha256, $checksum), 404);
|
||||
|
||||
return response()->file($path, [
|
||||
'Content-Type' => $document->media_type,
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Content-Security-Policy' => "sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
||||
'Cache-Control' => 'private, no-store',
|
||||
])->setContentDisposition('inline', basename($document->file_name));
|
||||
}
|
||||
|
||||
public function downloadForManager(string $id): BinaryFileResponse
|
||||
{
|
||||
$document = DB::table('documents')->where('id', $id)->whereNull('deleted_at')->first([
|
||||
'storage_key', 'file_name', 'sha256', 'status', 'malware_scan_status', 'malware_scanned_sha256',
|
||||
]);
|
||||
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless($checksum !== false && hash_equals($document->sha256, $checksum), 404);
|
||||
|
||||
return response()->download($path, basename($document->file_name), [
|
||||
'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
public function manage(MalwareScanner $scanner): Response
|
||||
{
|
||||
$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');
|
||||
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
|
||||
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
|
||||
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
|
||||
|
||||
$malwareHealth = $scanner->health();
|
||||
$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(),
|
||||
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
|
||||
'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]);
|
||||
'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,
|
||||
'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')
|
||||
->whereIn('location_type', ['country', 'province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type']),
|
||||
'incompleteCount' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where(fn ($q) => $q->whereNull('r.description')->orWhereNull('d.issuer')->orWhereNull('r.source_url')
|
||||
->orWhere('r.source_confirmation', 'unconfirmed')->orWhere('r.legal_status', 'unknown')->orWhere('r.rights_status', 'unconfirmed'))
|
||||
->count(),
|
||||
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
|
||||
}
|
||||
|
||||
public function upload(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
|
||||
$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'],
|
||||
'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.']);
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
$key = $file->storeAs('portal-documents', $id, 'local');
|
||||
abort_unless($key !== false, 500);
|
||||
try {
|
||||
DB::transaction(function () use ($file, $id, $key, $request) {
|
||||
DB::transaction(function () use ($file, $id, $key, $request, $checksum) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['investmoz-document-content:'.$checksum]);
|
||||
abort_unless(! DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists(), 409,
|
||||
'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.');
|
||||
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
|
||||
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']);
|
||||
'sha256' => $checksum, 'size_bytes' => $file->getSize(), 'issuer' => $request->input('issuer'),
|
||||
'original_source' => $request->input('original_source'), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'internal', 'rights_status' => 'unconfirmed']);
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']);
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'document_uploaded_to_quarantine']);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('local')->delete($key);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.');
|
||||
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.');
|
||||
}
|
||||
|
||||
public function resource(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
|
||||
'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]);
|
||||
DB::transaction(function () use ($data, $request) {
|
||||
abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.');
|
||||
$id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->audit($request, 'resource_registered', 'portal_resources', $id);
|
||||
$data = $this->validateResource($request);
|
||||
$id = DB::transaction(function () use ($data, $request): int {
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
|
||||
$identity = $this->identityKey($data);
|
||||
if ($identity) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
|
||||
}
|
||||
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
|
||||
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
|
||||
$this->assertPublicClassificationEligible($data, $document);
|
||||
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->syncResourceLinks($id, $data['associations'] ?? []);
|
||||
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
|
||||
DB::table('documents')->where('id', $document->id)->update(['issuer' => $data['issuer'] ?: $document->issuer,
|
||||
'issued_on' => $data['document_date'] ?: $document->issued_on, 'rights_status' => $data['rights_status'],
|
||||
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
|
||||
$this->audit($request, $duplicate ? 'resource_registered_possible_duplicate' : 'resource_registered', 'portal_resources', $id);
|
||||
|
||||
return $id;
|
||||
});
|
||||
|
||||
return back()->with('success', 'Documento catalogado.');
|
||||
return back()->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
|
||||
{
|
||||
$data = $this->validateResource($request);
|
||||
$expectedVersion = (int) $request->validate(['metadata_version' => ['required', 'integer', 'min:1']])['metadata_version'];
|
||||
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
|
||||
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($resource !== null, 404);
|
||||
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
|
||||
$identity = $this->identityKey($data);
|
||||
if ($identity) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
|
||||
}
|
||||
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
|
||||
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
|
||||
$this->assertPublicClassificationEligible($data, $document);
|
||||
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
|
||||
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
|
||||
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
|
||||
&& $document->status === 'active' && ($data['data_classification'] ?? $document->data_classification) === 'public' && $document->rights_status === 'authorized'
|
||||
&& $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
|
||||
DB::table('portal_resources')->where('id', $id)->update([...$fields, 'approved_by' => $retainPublication ? $resource->approved_by : null, 'published' => $retainPublication,
|
||||
'metadata_version' => $expectedVersion + 1, 'updated_at' => now()]);
|
||||
DB::table('portal_resource_links')->where('resource_id', $id)->delete();
|
||||
$this->syncResourceLinks($id, $data['associations'] ?? []);
|
||||
$this->recordResourceRevision($id, 'metadata_updated', $request->user()->id, $expectedVersion + 1);
|
||||
DB::table('documents')->where('id', $document->id)->update(['issuer' => $data['issuer'] ?: $document->issuer,
|
||||
'issued_on' => $data['document_date'] ?: $document->issued_on, 'rights_status' => $data['rights_status'],
|
||||
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
|
||||
$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.');
|
||||
}
|
||||
|
||||
public function moderate(Request $request, string $type, int $id): RedirectResponse
|
||||
@@ -184,9 +457,33 @@ class PortalServicesController extends Controller
|
||||
$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);
|
||||
if ($type === 'resources' && $data['published']) {
|
||||
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->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.');
|
||||
}
|
||||
}
|
||||
DB::table($table)->where('id', $id)->update($type === 'resources'
|
||||
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
|
||||
? ['published' => $data['published'], 'approved_by' => $data['published'] ? $request->user()->id : null,
|
||||
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'metadata_version' => DB::raw('metadata_version + 1'), 'updated_at' => now()]
|
||||
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
|
||||
if ($type === 'resources') {
|
||||
$this->recordResourceRevision($id, $data['published'] ? 'published' : 'unpublished', $request->user()->id,
|
||||
(int) DB::table('portal_resources')->where('id', $id)->value('metadata_version'));
|
||||
}
|
||||
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
|
||||
});
|
||||
|
||||
@@ -198,4 +495,133 @@ class PortalServicesController extends Controller
|
||||
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)]);
|
||||
}
|
||||
|
||||
private function validateResource(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'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'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
|
||||
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
|
||||
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'applicable_until' => ['nullable', 'date_format:Y-m-d'],
|
||||
'language' => ['required', 'string', 'max:12'], 'version_label' => ['nullable', 'string', 'max:120'],
|
||||
'document_group_id' => ['nullable', 'uuid'], 'territorial_scope' => ['required', 'in:national,provincial,district,locality,multiple,unknown'],
|
||||
'territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'source_confirmation' => ['required', 'in:unconfirmed,identified,official_confirmed'],
|
||||
'legal_status' => ['required', 'in:not_applicable,in_force,historical,proposed,repealed,amended,unknown'],
|
||||
'rights_status' => ['required', 'in:unconfirmed,authorized,restricted'], 'independent_review' => ['required', 'boolean'],
|
||||
'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.*.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'],
|
||||
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
|
||||
'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.']);
|
||||
}
|
||||
foreach ($data['associations'] ?? [] as $index => $association) {
|
||||
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.']);
|
||||
}
|
||||
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.']);
|
||||
}
|
||||
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.']);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
|
||||
{
|
||||
unset($data['associations'], $data['issuer'], $data['data_classification']);
|
||||
$data['document_identity_key'] = $identity;
|
||||
$data['duplicate_of_resource_id'] = $duplicate;
|
||||
$data['last_reviewed_at'] = $data['source_confirmation'] !== 'unconfirmed' || $data['rights_status'] === 'authorized' ? now() : null;
|
||||
$data['last_reviewed_by'] = $actor;
|
||||
if ($creating) {
|
||||
$data['created_by'] = $actor;
|
||||
} else {
|
||||
unset($data['metadata_version']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function syncResourceLinks(int $resourceId, array $links): void
|
||||
{
|
||||
foreach ($links as $link) {
|
||||
if ($link['target_type'] === 'document') {
|
||||
abort_unless(DB::table('portal_resources')->where('id', $link['target_key'])->exists(), 422, 'A relação documental seleccionada não existe.');
|
||||
}
|
||||
DB::table('portal_resource_links')->insert([
|
||||
'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',
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPublicClassificationEligible(array $data, object $document): void
|
||||
{
|
||||
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 422,
|
||||
'A análise antimalware e a integridade do ficheiro têm de estar confirmadas antes de o classificar como público.');
|
||||
abort_unless($data['rights_status'] === 'authorized', 422,
|
||||
'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
|
||||
{
|
||||
if (empty($data['issuer']) || empty($data['document_number'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash('sha256', mb_strtolower(implode('|', [trim($data['issuer']), trim($data['document_number']),
|
||||
$data['document_date'] ?? '', $data['language'], $data['version_label'] ?? ''])));
|
||||
}
|
||||
|
||||
private function recordResourceRevision(int $id, string $changeType, int $actor, int $revision): void
|
||||
{
|
||||
$resource = DB::table('portal_resources')->where('id', $id)->first();
|
||||
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
|
||||
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => $changeType,
|
||||
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
|
||||
'actor_user_id' => $actor, 'created_at' => now()]);
|
||||
}
|
||||
|
||||
private function territoryScopeIds(string $id): array
|
||||
{
|
||||
return collect(DB::select(<<<'SQL'
|
||||
WITH RECURSIVE territory_tree AS (
|
||||
SELECT id FROM geo_locations WHERE id = ? AND deleted_at IS NULL AND data_classification = 'public'
|
||||
UNION ALL
|
||||
SELECT child.id FROM geo_locations child
|
||||
INNER JOIN territory_tree parent ON child.parent_id = parent.id
|
||||
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
|
||||
)
|
||||
SELECT id FROM territory_tree
|
||||
SQL, [$id]))->pluck('id')->all() ?: [$id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Http\Resources\PublicBusinessOpportunityResource;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Services\ExistingBusinessDrafts;
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -20,9 +21,22 @@ class PublicBusinessOpportunityController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
'sector' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
|
||||
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
$query = $this->publishedQuery();
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->whereExists(fn ($locations) => $locations->selectRaw('1')->from('existing_business_locations as matching_location')
|
||||
->whereColumn('matching_location.existing_business_id', 'existing_businesses.id')->where('matching_location.is_public', true)
|
||||
->where(function ($match) use ($territoryIds): void {
|
||||
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
|
||||
$match->orWhereIn('matching_location.'.$field, $territoryIds);
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = trim($filters['q']);
|
||||
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
|
||||
@@ -38,7 +52,7 @@ class PublicBusinessOpportunityController extends Controller
|
||||
return $results->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters]);
|
||||
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response|JsonResponse
|
||||
@@ -49,16 +63,23 @@ class PublicBusinessOpportunityController extends Controller
|
||||
return $resource->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource]);
|
||||
$returnFilters = $request->query();
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource,
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
|
||||
}
|
||||
|
||||
public function interest(Request $request, string $slug): Response
|
||||
{
|
||||
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
|
||||
|
||||
$returnFilters = $request->query();
|
||||
|
||||
return Inertia::render('investment/business-interest', [
|
||||
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
|
||||
'modalities' => ExistingBusinessDrafts::MODALITIES,
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,21 +5,28 @@ namespace App\Http\Controllers;
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PublicGeographyController extends Controller
|
||||
{
|
||||
public function options(Request $request, InvestmentGeography $geography): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$query = $geography->publicReferences()->where('location_type', $data['type']);
|
||||
if (! empty($data['id'])) {
|
||||
$query->where('id', $data['id']);
|
||||
}
|
||||
if ($data['type'] !== 'province') {
|
||||
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
|
||||
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
|
||||
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$term = '%'.addcslashes($data['q'], '%_\\').'%';
|
||||
$query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term]));
|
||||
$term = '%'.addcslashes(Str::ascii($data['q']), '%_\\').'%';
|
||||
$query->where(fn ($q) => $q
|
||||
->whereRaw("translate(lower(name), 'áàâãäåéèêëíìîïóòôõöúùûüçýÿ', 'aaaaaaeeeeiiiiooooouuuucyy') ILIKE ?", [$term])
|
||||
->orWhereRaw('code ILIKE ?', [$term])
|
||||
->orWhereRaw('source_code ILIKE ?', [$term]));
|
||||
}
|
||||
|
||||
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code']));
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use App\Services\SimulationTaxRuleExpression;
|
||||
use App\Services\SimulationTaxRuleGovernance;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SimulationTaxRuleGovernanceController extends Controller
|
||||
{
|
||||
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'rule_code' => ['nullable', 'string', 'max:64'],
|
||||
'component' => ['nullable', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'scope_key' => ['nullable', 'string', 'max:160'],
|
||||
'status' => ['nullable', 'in:draft,submitted,reviewed,rejected,approved,published,suspended,archived'],
|
||||
'per_page' => ['nullable', 'integer', 'between:1,100'],
|
||||
]);
|
||||
|
||||
return response()->json($governance->listing($request->user(), $filters));
|
||||
}
|
||||
|
||||
public function store(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
|
||||
{
|
||||
$rule = $governance->create($request->user(), $this->validatedRule($request));
|
||||
|
||||
return response()->json($rule, 201)->header('Location', url('/api/v2/investment/admin/tax-rules/'.$rule->id));
|
||||
}
|
||||
|
||||
public function show(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
|
||||
{
|
||||
return response()->json($governance->show($request->user(), $id));
|
||||
}
|
||||
|
||||
public function update(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($governance->update($request->user(), $id, (int) $data['version_no'], $this->validatedRule($request)));
|
||||
}
|
||||
|
||||
public function transition(Request $request, SimulationTaxRuleGovernance $governance, string $id, string $action): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version_no' => ['required', 'integer', 'min:1'],
|
||||
'evidence' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
]);
|
||||
|
||||
return response()->json($governance->transition($request->user(), $id, (int) $data['version_no'], $action, $data['evidence']));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function validatedRule(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'rule_code' => ['required', 'string', 'regex:/^[A-Z0-9][A-Z0-9._-]{2,63}$/'],
|
||||
'rule_version' => ['required', 'integer', 'min:1'],
|
||||
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'scope_key' => ['required', 'string', 'min:2', 'max:160'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
'legal_source_id' => ['required', 'uuid', 'exists:investment_legal_sources,id'],
|
||||
'source_sha256' => ['required', 'regex:/^[a-f0-9]{64}$/'],
|
||||
'rule_definition' => ['required', 'array'],
|
||||
'parameters' => ['required', 'array'],
|
||||
'rounding_policy' => ['required', 'array'],
|
||||
]);
|
||||
foreach (['rule_definition', 'parameters', 'rounding_policy'] as $key) {
|
||||
$json = json_encode($data[$key], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
Validator::make(['json' => $json], ['json' => ['max:50000']])->validate();
|
||||
}
|
||||
|
||||
if (($data['rule_definition']['language'] ?? null) === 'investmoz.tax.v1') {
|
||||
try {
|
||||
app(SimulationTaxRuleExpression::class)->validate($data['rule_definition']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['rule_definition' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
try {
|
||||
app(SimulationTaxParameterResolver::class)->validateParameters($data['parameters']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['parameters' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureSimulationRecoveryCookie
|
||||
{
|
||||
public const NAME = 'investmoz_simulation_recovery';
|
||||
|
||||
public const DAYS = 30;
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->cookie(self::NAME);
|
||||
$issued = ! is_string($token) || strlen($token) < 32 || strlen($token) > 256;
|
||||
if (! is_string($token) || strlen($token) < 32 || strlen($token) > 256) {
|
||||
$provided = $request->header('X-Simulation-Token');
|
||||
$token = is_string($provided) && strlen($provided) >= 32 && strlen($provided) <= 256
|
||||
? $provided
|
||||
: Str::random(96);
|
||||
}
|
||||
$request->headers->set('X-Simulation-Token', $token);
|
||||
|
||||
$response = $next($request);
|
||||
$forget = $request->isMethod('DELETE')
|
||||
&& $request->path() === 'api/v1/simulation-drafts/current'
|
||||
&& $response->getStatusCode() === 204;
|
||||
$activity = $response->isSuccessful() && $this->isExplicitDraftActivity($request, $response);
|
||||
$replay = $response->isSuccessful() && $this->isDraftActivityReplay($request, $response);
|
||||
|
||||
if (! $issued && ! $activity && ! $replay && ! $forget) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Successful draft writes reset the server deadline to now + DAYS, so
|
||||
// refresh the cookie by the same duration. Routine reads never refresh it.
|
||||
$minutes = $forget ? 0 : self::DAYS * 24 * 60;
|
||||
if ($replay) {
|
||||
$payload = json_decode((string) $response->getContent(), true);
|
||||
$expiry = is_array($payload) ? ($payload['expires_at'] ?? null) : null;
|
||||
if (is_string($expiry)) {
|
||||
try {
|
||||
$secondsLeft = \Illuminate\Support\Carbon::parse($expiry)->getTimestamp() - now()->getTimestamp();
|
||||
$minutes = max(1, (int) ceil($secondsLeft / 60));
|
||||
} catch (\Throwable) {
|
||||
// Preserve the standard cookie lifetime if the response has no valid deadline.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $response->withCookie(cookie(
|
||||
self::NAME,
|
||||
$forget ? '' : $token,
|
||||
$minutes,
|
||||
'/api/v1',
|
||||
null,
|
||||
$request->isSecure(),
|
||||
true,
|
||||
false,
|
||||
'lax',
|
||||
));
|
||||
}
|
||||
|
||||
private function isExplicitDraftActivity(Request $request, Response $response): bool
|
||||
{
|
||||
if ($request->isMethod('POST') && $request->path() === 'api/v1/simulation-drafts') {
|
||||
return $response->getStatusCode() === 201;
|
||||
}
|
||||
|
||||
return ($request->isMethod('PUT') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}$#i', $request->path()) === 1)
|
||||
|| ($request->isMethod('POST') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/(?:conversions|touch)$#i', $request->path()) === 1);
|
||||
}
|
||||
|
||||
private function isDraftActivityReplay(Request $request, Response $response): bool
|
||||
{
|
||||
if (! $request->isMethod('POST') || $response->getStatusCode() !== 200) {
|
||||
return false;
|
||||
}
|
||||
if ($request->path() !== 'api/v1/simulation-drafts'
|
||||
&& preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/conversions$#i', $request->path()) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response->getContent(), true);
|
||||
|
||||
return is_array($payload) && ($payload['data']['replayed'] ?? $payload['replayed'] ?? false) === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachSimulationToDraftRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<mixed>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'simulation_id' => ['required', 'uuid'],
|
||||
'consent_reuse' => ['required', 'accepted'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ClaimSimulationFormDraftRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->hasVerifiedEmail() === true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'choice' => ['required', Rule::in(['continue_existing', 'save_as_new'])],
|
||||
'existing_draft_id' => ['required_if:choice,continue_existing', 'nullable', 'uuid'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LinkSimulationDraftBusinessRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->hasVerifiedEmail() === true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return ['business_id' => ['required', 'uuid']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class ResolveSimulationTaxParametersRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,list<mixed>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'period_start' => ['required', 'date_format:Y-m-d'],
|
||||
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
|
||||
'context' => ['sometimes', 'array:fiscal_periodicity,fiscal_operation,fiscal_residency_assessment,fiscal_residency_country,fiscal_income_category,fiscal_establishments,fiscal_documentation,fiscal_customs_code,fiscal_vat_refund_state'],
|
||||
'context.*' => ['string', 'max:160'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$start = $this->parseEffectiveDate('period_start');
|
||||
$end = $this->parseEffectiveDate('period_end');
|
||||
if ($start !== null && $end !== null && ($start->diff($end)->days ?? 0) > 365) {
|
||||
$validator->errors()->add('period_end', 'O período de resolução não pode exceder 366 dias.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function parseEffectiveDate(string $key): ?\DateTimeImmutable
|
||||
{
|
||||
$value = $this->input($key);
|
||||
if (! is_string($value) || preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
|
||||
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreLandAssistanceCaseRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function payload(): array
|
||||
{
|
||||
$payload = $this->validated('payload');
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,list<mixed>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
|
||||
'payload' => ['required', 'array:support_kind,request_description,project_reference,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
|
||||
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
|
||||
'payload.request_description' => ['nullable', 'string', 'max:4000'],
|
||||
'payload.project_reference' => ['nullable', 'string', 'max:160'],
|
||||
'payload.applicant_category' => ['nullable', 'in:individual,entity,community,other'],
|
||||
'payload.full_name' => ['nullable', 'string', 'max:150'],
|
||||
'payload.contact_person' => ['nullable', 'string', 'max:200'],
|
||||
'payload.email' => ['nullable', 'email:rfc', 'max:150'],
|
||||
'payload.phone' => ['nullable', 'string', 'max:30'],
|
||||
'payload.preferred_contact' => ['nullable', 'in:email,phone,any'],
|
||||
'payload.country' => ['nullable', 'string', 'max:100'],
|
||||
'payload.nationality' => ['nullable', 'string', 'max:120'],
|
||||
'payload.representative_name' => ['nullable', 'string', 'max:200'],
|
||||
'payload.representative_role' => ['nullable', 'string', 'max:120'],
|
||||
'payload.representation_basis' => ['nullable', 'string', 'max:500'],
|
||||
'payload.nuit' => ['nullable', 'string', 'max:40'],
|
||||
'payload.constitution_or_residency_note' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.area_status' => ['nullable', 'in:identified,seeking,location_support,unknown'],
|
||||
'payload.parcels' => ['nullable', 'array', 'max:10'],
|
||||
'payload.parcels.*' => ['array:province_id,district_id,admin_post_id,locality_id,place_description,area,area_unit,cadastral_reference,coordinates,geometry_confidence'],
|
||||
'payload.parcels.*.province_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'payload.parcels.*.district_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'payload.parcels.*.admin_post_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'payload.parcels.*.locality_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'payload.parcels.*.place_description' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.parcels.*.area' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
|
||||
'payload.parcels.*.area_unit' => ['nullable', 'in:m2,hectares,other,unknown'],
|
||||
'payload.parcels.*.cadastral_reference' => ['nullable', 'string', 'max:160'],
|
||||
'payload.parcels.*.coordinates' => ['nullable', 'string', 'max:500'],
|
||||
'payload.parcels.*.geometry_confidence' => ['nullable', 'in:declared,approximate,confirmed_by_authority,unknown'],
|
||||
'payload.purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.activity' => ['nullable', 'string', 'max:500'],
|
||||
'payload.known_occupants' => ['nullable', 'in:yes,no_as_far_as_known,unknown'],
|
||||
'payload.occupant_description' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.known_improvements' => ['nullable', 'in:yes,unknown,none_identified'],
|
||||
'payload.improvements_description' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.prior_right_status' => ['nullable', 'in:yes,unknown,no_documents'],
|
||||
'payload.prior_right_description' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.conflict_status' => ['nullable', 'in:yes,unknown,no_knowledge'],
|
||||
'payload.conflict_description' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.official_process' => ['nullable', 'array:official_reference,receiving_entity,presented_at,reported_status,pending_items,proof_available'],
|
||||
'payload.official_process.official_reference' => ['nullable', 'string', 'max:160'],
|
||||
'payload.official_process.receiving_entity' => ['nullable', 'string', 'max:200'],
|
||||
'payload.official_process.presented_at' => ['nullable', 'date_format:Y-m-d'],
|
||||
'payload.official_process.reported_status' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.official_process.pending_items' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.official_process.proof_available' => ['nullable', 'in:yes,no,unknown'],
|
||||
'payload.attachments_to_follow' => ['nullable', 'array', 'max:10'],
|
||||
'payload.attachments_to_follow.*' => ['string', 'in:sketch,prior_right,exploration_plan,official_receipt,consultation_minutes,photo,other'],
|
||||
'payload.exploration_plan' => ['nullable', 'array:activity_rows,notes'],
|
||||
'payload.exploration_plan.activity_rows' => ['nullable', 'array', 'max:30'],
|
||||
'payload.exploration_plan.activity_rows.*' => ['array:period,activity,area,area_unit,stage,targets,structures,investment'],
|
||||
'payload.exploration_plan.activity_rows.*.period' => ['nullable', 'string', 'max:80'],
|
||||
'payload.exploration_plan.activity_rows.*.activity' => ['nullable', 'string', 'max:300'],
|
||||
'payload.exploration_plan.activity_rows.*.area' => ['nullable', 'numeric', 'min:0'],
|
||||
'payload.exploration_plan.activity_rows.*.area_unit' => ['nullable', 'in:m2,hectares,other,unknown'],
|
||||
'payload.exploration_plan.activity_rows.*.stage' => ['nullable', 'in:existing,planned,unknown'],
|
||||
'payload.exploration_plan.activity_rows.*.targets' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.exploration_plan.activity_rows.*.structures' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.exploration_plan.activity_rows.*.investment' => ['nullable', 'numeric', 'min:0'],
|
||||
'payload.exploration_plan.notes' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.consultation' => ['nullable', 'array:status,community,location,date,facilitators,language,participants,interventions,opposition,agreements,minutes_reference,opinions_received'],
|
||||
'payload.consultation.status' => ['nullable', 'in:not_started,preparing,reported_completed,unknown'],
|
||||
'payload.consultation.community' => ['nullable', 'string', 'max:200'],
|
||||
'payload.consultation.location' => ['nullable', 'string', 'max:500'],
|
||||
'payload.consultation.date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'payload.consultation.facilitators' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.consultation.language' => ['nullable', 'string', 'max:100'],
|
||||
'payload.consultation.participants' => ['nullable', 'array', 'max:200'],
|
||||
'payload.consultation.participants.*' => ['array:name,role'],
|
||||
'payload.consultation.participants.*.name' => ['nullable', 'string', 'max:200'],
|
||||
'payload.consultation.participants.*.role' => ['nullable', 'string', 'max:120'],
|
||||
'payload.consultation.interventions' => ['nullable', 'string', 'max:4000'],
|
||||
'payload.consultation.opposition' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.consultation.agreements' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.consultation.minutes_reference' => ['nullable', 'string', 'max:500'],
|
||||
'payload.consultation.opinions_received' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.additional_information' => ['nullable', 'string', 'max:4000'],
|
||||
'payload.truth_declaration' => ['nullable', 'boolean'],
|
||||
'payload.contact_consent' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$payload = $this->input('payload', []);
|
||||
if (is_array($payload) && is_array($payload['parcels'] ?? null)) {
|
||||
$geography = app(InvestmentGeography::class);
|
||||
$levels = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
|
||||
foreach ($payload['parcels'] as $index => $parcel) {
|
||||
if (! is_array($parcel)) {
|
||||
continue;
|
||||
}
|
||||
$parent = null;
|
||||
$gap = false;
|
||||
foreach ($levels as $field => $expectedType) {
|
||||
$id = $parcel[$field] ?? null;
|
||||
if (! is_string($id) || $id === '') {
|
||||
$gap = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
$record = $geography->publicReferences()->where('id', $id)->first(['id', 'parent_id', 'location_type']);
|
||||
if ($gap || ! $record || $record->location_type !== $expectedType || ($expectedType === 'province' ? $record->parent_id !== null : (string) $record->parent_id !== $parent)) {
|
||||
$validator->errors()->add("payload.parcels.$index.$field", 'A hierarquia territorial declarada é incoerente ou não está publicada.');
|
||||
$gap = true;
|
||||
}
|
||||
$parent = (string) $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->routeIs('land-assistance.support')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
foreach (['support_kind', 'request_description', 'applicant_category', 'full_name', 'preferred_contact', 'area_status', 'purpose'] as $required) {
|
||||
if (! isset($payload[$required]) || trim((string) $payload[$required]) === '') {
|
||||
$validator->errors()->add('payload.'.$required, 'Este campo é necessário para solicitar apoio.');
|
||||
}
|
||||
}
|
||||
if (empty($payload['email']) && empty($payload['phone'])) {
|
||||
$validator->errors()->add('payload.email', 'Indique pelo menos um contacto utilizável.');
|
||||
}
|
||||
if (! empty($payload['email']) && ! filter_var($payload['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$validator->errors()->add('payload.email', 'Indique um email válido para registar o pedido no balcão de apoio.');
|
||||
}
|
||||
if (($payload['applicant_category'] ?? null) === 'community' && empty($payload['contact_person'])) {
|
||||
$validator->errors()->add('payload.contact_person', 'Indique um contacto para a comunidade, se conhecido.');
|
||||
}
|
||||
if (($payload['applicant_category'] ?? null) === 'entity' && empty($payload['representative_role'])) {
|
||||
$validator->errors()->add('payload.representative_role', 'Indique a qualidade da representação conhecida.');
|
||||
}
|
||||
if (empty($payload['contact_consent'])) {
|
||||
$validator->errors()->add('payload.contact_consent', 'A autorização de contacto é necessária para solicitar apoio.');
|
||||
}
|
||||
if (empty($payload['truth_declaration'])) {
|
||||
$validator->errors()->add('payload.truth_declaration', 'Confirme que as respostas refletem a informação de que dispõe.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,24 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreSimulationFormDraftRequest extends FormRequest
|
||||
{
|
||||
/** @return array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} */
|
||||
public function snapshots(): array
|
||||
{
|
||||
$validated = $this->validated();
|
||||
$personal = $validated['personal_snapshot'] ?? null;
|
||||
$economic = $validated['economic_snapshot'] ?? null;
|
||||
if (! is_array($personal) || ! is_array($economic)) {
|
||||
throw new \LogicException('Validated simulation snapshots must be arrays.');
|
||||
}
|
||||
|
||||
return ['personal_snapshot' => $personal, 'economic_snapshot' => $economic];
|
||||
}
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
@@ -17,13 +30,15 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$personal = [
|
||||
'person_type', 'investor_origin', 'tax_residency_country', 'entity_stage', 'legal_form', 'nuit',
|
||||
'person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'nuit',
|
||||
'investor_profile_id', 'profile_confirmation', 'legal_name', 'commercial_name', 'registration_number',
|
||||
'representative_name', 'representative_capacity', 'email', 'phone', 'address', 'contact_country', 'selection_labels',
|
||||
'contact_province', 'contact_district', 'preferred_channel',
|
||||
];
|
||||
$economic = [
|
||||
'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'selection_labels', 'project_type',
|
||||
'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
|
||||
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
|
||||
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
|
||||
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
|
||||
'project_duration_years', 'existing_project_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id',
|
||||
'site_known', 'latitude', 'longitude', 'area_required', 'area_unit', 'special_zone_id', 'land_status',
|
||||
@@ -31,9 +46,12 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'grants', 'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit',
|
||||
'financing_term_months', 'funds_origin_countries', 'direct_jobs', 'indirect_jobs', 'national_jobs',
|
||||
'foreign_jobs', 'skills_needed', 'local_content_percentage', 'exports_value', 'import_substitution_value',
|
||||
'environmental_category', 'community_benefits', 'tax_calculation', 'base', 'rate', 'reduction',
|
||||
'environmental_category', 'community_benefits', 'tax_calculation', 'fiscal_scenario_mode', 'base', 'rate', 'reduction',
|
||||
'sales_taxable', 'sales_exempt', 'sales_zero_rate', 'exports', 'deductible_costs', 'non_deductible_costs',
|
||||
'carried_losses', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'reference_date',
|
||||
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment',
|
||||
'fiscal_operation', 'fiscal_activity', 'fiscal_establishments', 'fiscal_documentation',
|
||||
'fiscal_customs_code', 'fiscal_vat_refund_state',
|
||||
'offer_or_demand', 'opportunity_type', 'opportunity_type_other', 'opportunity_description',
|
||||
'opportunity_value', 'opportunity_currency', 'opportunity_conditions', 'opportunity_deadline',
|
||||
'required_documents', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration', 'reuse_simulation',
|
||||
@@ -42,9 +60,24 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
];
|
||||
|
||||
$rules = [
|
||||
'personal_snapshot' => ['required', 'array:'.implode(',', $personal)],
|
||||
// Anonymous clients must send an empty snapshot; `required` rejects
|
||||
// an empty array before the privacy policy can enforce that shape.
|
||||
'personal_snapshot' => ['present', 'array:'.implode(',', $personal)],
|
||||
'economic_snapshot' => ['required', 'array:'.implode(',', $economic)],
|
||||
'economic_snapshot.purpose' => ['required', 'in:simulate_only,own_investment,seek_investor,seek_partner,present_opportunity,request_facilitation'],
|
||||
'economic_snapshot.journey_type' => ['nullable', 'in:personal,small_business,company,investor'],
|
||||
'economic_snapshot.detail_level' => ['nullable', 'in:simple,advanced'],
|
||||
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
|
||||
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
|
||||
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
|
||||
'economic_snapshot.project_execution_snapshot' => ['nullable', 'string', 'max:30000'],
|
||||
'economic_snapshot.project_risk_responses_snapshot' => ['nullable', 'string', 'max:30000'],
|
||||
'economic_snapshot.project_questionnaire_version' => ['nullable', 'string', 'max:100'],
|
||||
'economic_snapshot.customer_validation_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
|
||||
'economic_snapshot.cost_estimate_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
|
||||
'economic_snapshot.location_assessment_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
|
||||
'economic_snapshot.receivables_timing_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
|
||||
'economic_snapshot.funding_status' => ['nullable', 'in:confirmed,in_process,not_started,unknown,not_applicable'],
|
||||
'economic_snapshot.manifestation_type' => ['nullable', 'in:investment,business'],
|
||||
'economic_snapshot.preferred_language' => ['nullable', 'in:pt-MZ,en'],
|
||||
'economic_snapshot.project_type' => ['nullable', 'in:new,expansion,acquisition,partnership,concession,supplier,buyer,other'],
|
||||
@@ -55,6 +88,19 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'economic_snapshot.confidentiality' => ['nullable', 'in:public,registered_users,restricted,confidential'],
|
||||
'economic_snapshot.site_known' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.tax_calculation' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.fiscal_scenario_mode' => ['nullable', 'in:approved_parameters,custom_planning,no_calculation'],
|
||||
'economic_snapshot.fiscal_period' => ['nullable', 'integer', 'in:'.implode(',', range(2000, today()->year + 1))],
|
||||
'economic_snapshot.fiscal_periodicity' => ['nullable', 'in:monthly,quarterly,annual,one_off,unknown'],
|
||||
'economic_snapshot.fiscal_month' => ['nullable', 'regex:/^(0[1-9]|1[0-2])$/'],
|
||||
'economic_snapshot.fiscal_quarter' => ['nullable', 'in:Q1,Q2,Q3,Q4'],
|
||||
'economic_snapshot.fiscal_reference_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'economic_snapshot.irps_salary_view' => ['nullable', 'in:monthly_net_estimate,monthly_withholding,annual_assessment'],
|
||||
'economic_snapshot.fiscal_component' => ['nullable', 'in:irpc,iva,ispc,irps,inss,withholding,customs_ice'],
|
||||
'economic_snapshot.fiscal_income_category' => ['nullable', 'in:employment,rental,digital,capital_gain,business,other,unknown'],
|
||||
'economic_snapshot.fiscal_residency_assessment' => ['nullable', 'in:self_reported_resident,self_reported_non_resident,unknown'],
|
||||
'economic_snapshot.fiscal_operation' => ['nullable', 'in:domestic,non_resident,import,export,refund,other,unknown'],
|
||||
'economic_snapshot.fiscal_documentation' => ['nullable', 'in:documented,partly_documented,not_documented,unknown'],
|
||||
'economic_snapshot.fiscal_vat_refund_state' => ['nullable', 'in:estimate_only,request_filed,recognized,received,unknown'],
|
||||
'economic_snapshot.publish_requested' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.terms_accepted' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.truth_declaration' => ['nullable', 'in:yes,no'],
|
||||
@@ -85,17 +131,21 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
foreach (['direct_jobs', 'indirect_jobs', 'national_jobs', 'foreign_jobs', 'financing_term_months', 'project_duration_years'] as $field) {
|
||||
$rules['economic_snapshot.'.$field] = ['nullable', 'integer', 'min:0'];
|
||||
}
|
||||
$rules['economic_snapshot.fiscal_establishments'] = ['nullable', 'integer', 'min:1'];
|
||||
foreach (['fiscal_activity', 'fiscal_customs_code'] as $field) {
|
||||
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:300'];
|
||||
}
|
||||
foreach (['infrastructure_needs', 'funds_origin_countries', 'required_documents'] as $field) {
|
||||
$rules['economic_snapshot.'.$field] = ['nullable', 'array', 'max:100'];
|
||||
$rules['economic_snapshot.'.$field.'.*'] = ['nullable', 'string', 'max:3000'];
|
||||
}
|
||||
$rules['economic_snapshot.infrastructure_needs.*'] = ['nullable', 'in:energia,água,estrada,porto,ferrovia,telecomunicações,armazenamento'];
|
||||
$rules['economic_snapshot.infrastructure_needs.*'] = ['nullable', 'in:energia,água,estrada,porto,ferrovia,telecomunicações,armazenamento,customers,raw_materials,workers,facilities,costs,legal_requirements'];
|
||||
$rules['economic_snapshot.required_documents.*'] = ['nullable', 'in:identity_document,company_registration,nuit_certificate,company_profile,business_plan,feasibility_study,proof_of_funds,special_zone_certificate,licence,site_map'];
|
||||
foreach (['skills_needed', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'opportunity_conditions'] as $field) {
|
||||
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:10000'];
|
||||
}
|
||||
$rules['economic_snapshot.selection_labels'] = ['nullable', 'array'];
|
||||
foreach (['project_summary', 'opportunity_description', 'products_services', 'community_benefits'] as $field) {
|
||||
foreach (['project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'opportunity_description', 'products_services', 'community_benefits', 'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale'] as $field) {
|
||||
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:10000'];
|
||||
}
|
||||
$rules['personal_snapshot.email'] = ['nullable', 'email:rfc', 'max:254'];
|
||||
@@ -114,7 +164,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
|
||||
$rules['economic_snapshot.'.$field] = ['nullable', 'uuid', 'exists:geo_locations,id'];
|
||||
}
|
||||
foreach (['person_type', 'investor_origin', 'tax_residency_country', 'entity_stage', 'legal_form', 'commercial_name',
|
||||
foreach (['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'commercial_name',
|
||||
'registration_number', 'representative_name', 'representative_capacity', 'phone', 'address', 'contact_country',
|
||||
'contact_province', 'contact_district', 'preferred_channel'] as $field) {
|
||||
$rules['personal_snapshot.'.$field] = ['nullable', 'string', 'max:3000'];
|
||||
@@ -131,14 +181,100 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'project_team_snapshot' => [
|
||||
'keys' => ['competency', 'state', 'action', 'estimated_cost', 'due_date'],
|
||||
'states' => ['available', 'gap', 'unknown'],
|
||||
'amounts' => ['estimated_cost'],
|
||||
],
|
||||
'project_execution_snapshot' => [
|
||||
'keys' => ['title', 'due_date', 'status', 'planned_amount', 'actual_amount'],
|
||||
'states' => ['planned', 'in_progress', 'completed'],
|
||||
'amounts' => ['planned_amount', 'actual_amount'],
|
||||
],
|
||||
] as $field => $schema) {
|
||||
if (! isset($economic[$field]) || $economic[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
$rows = json_decode((string) $economic[$field], true);
|
||||
$valid = is_array($rows) && count($rows) <= 100;
|
||||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||||
if (! is_array($row) || array_diff(array_keys($row), $schema['keys']) !== []
|
||||
|| array_diff($schema['keys'], array_keys($row)) !== []
|
||||
|| array_filter($row, fn ($value) => ! is_string($value) || mb_strlen($value) > 500) !== []) {
|
||||
$valid = false;
|
||||
break;
|
||||
}
|
||||
$stateKey = $field === 'project_team_snapshot' ? 'state' : 'status';
|
||||
if (! in_array($row[$stateKey], $schema['states'], true)) {
|
||||
$valid = false;
|
||||
break;
|
||||
}
|
||||
foreach ($schema['amounts'] as $amountKey) {
|
||||
if ($row[$amountKey] !== '' && (! is_numeric($row[$amountKey]) || (float) $row[$amountKey] < 0)) {
|
||||
$valid = false;
|
||||
}
|
||||
}
|
||||
foreach (['due_date'] as $dateKey) {
|
||||
if ($row[$dateKey] !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $row[$dateKey])) {
|
||||
$valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! $valid) {
|
||||
$validator->errors()->add('economic_snapshot.'.$field, 'O plano estruturado contém linhas ou valores inválidos.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($economic['project_risk_responses_snapshot']) && $economic['project_risk_responses_snapshot'] !== '') {
|
||||
$responses = json_decode((string) $economic['project_risk_responses_snapshot'], true);
|
||||
$validResponses = is_array($responses) && array_is_list($responses) && count($responses) <= 30;
|
||||
foreach (is_array($responses) ? $responses : [] as $response) {
|
||||
if (! is_array($response)
|
||||
|| array_diff(array_keys($response), ['risk_id', 'action', 'owner', 'estimated_cost', 'due_date']) !== []
|
||||
|| array_diff(['risk_id', 'action', 'owner', 'estimated_cost', 'due_date'], array_keys($response)) !== []
|
||||
|| array_filter($response, fn ($value) => ! is_string($value) || mb_strlen($value) > 500) !== []
|
||||
|| ! preg_match('/^[a-z0-9-]{1,80}$/', $response['risk_id'])
|
||||
|| ($response['estimated_cost'] !== '' && (! is_numeric($response['estimated_cost']) || (float) $response['estimated_cost'] < 0))
|
||||
|| ($response['due_date'] !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $response['due_date']))) {
|
||||
$validResponses = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $validResponses) {
|
||||
$validator->errors()->add('economic_snapshot.project_risk_responses_snapshot', 'As respostas planeadas para lacunas ou riscos não são válidas.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($economic['fiscal_scenario_mode'])) {
|
||||
$custom = $economic['fiscal_scenario_mode'] === 'custom_planning';
|
||||
$providedCount = 0;
|
||||
foreach (['base', 'rate', 'reduction'] as $field) {
|
||||
$present = isset($economic[$field]) && $economic[$field] !== '';
|
||||
$providedCount += $present ? 1 : 0;
|
||||
if (! $custom && $present) {
|
||||
$validator->errors()->add('economic_snapshot.'.$field, 'Valores hipotéticos só podem ser enviados no cenário personalizado.');
|
||||
}
|
||||
}
|
||||
if ($custom && $providedCount > 0 && $providedCount < 3) {
|
||||
foreach (['base', 'rate', 'reduction'] as $field) {
|
||||
if (! isset($economic[$field]) || $economic[$field] === '') {
|
||||
$validator->errors()->add('economic_snapshot.'.$field, 'Preencha todos os pressupostos se começar um cenário personalizado.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allowedLabels = [
|
||||
'economic_snapshot.selection_labels' => ['purpose', 'manifestation_type', 'preferred_language', 'project_type', 'sector_id', 'activity_id',
|
||||
'economic_snapshot.selection_labels' => ['journey_type', 'detail_level', 'purpose', 'manifestation_type', 'preferred_language', 'project_type', 'maturity_stage', 'sector_id', 'activity_id',
|
||||
'province_id', 'district_id', 'admin_post_id', 'locality_id', 'site_known', 'land_status', 'infrastructure_needs',
|
||||
'area_unit', 'required_documents', 'opportunity_currency',
|
||||
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'offer_or_demand',
|
||||
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'fiscal_scenario_mode', 'offer_or_demand',
|
||||
'opportunity_type', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration',
|
||||
'reuse_simulation', 'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged'],
|
||||
'personal_snapshot.selection_labels' => ['person_type', 'investor_origin', 'tax_residency_country', 'entity_stage', 'legal_form', 'preferred_channel'],
|
||||
'reuse_simulation', 'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged',
|
||||
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
|
||||
'fiscal_documentation', 'fiscal_vat_refund_state'],
|
||||
'personal_snapshot.selection_labels' => ['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'preferred_channel'],
|
||||
];
|
||||
foreach ($allowedLabels as $path => $allowedFields) {
|
||||
$snapshot = str_starts_with($path, 'personal_') ? $this->input('personal_snapshot.selection_labels', []) : ($economic['selection_labels'] ?? []);
|
||||
|
||||
@@ -11,21 +11,21 @@ class PublicBusinessOpportunityResource extends JsonResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->public_slug,
|
||||
'title' => $this->public_title,
|
||||
'activity_code' => $this->activity_code,
|
||||
'stage' => $this->stage,
|
||||
'objective' => $this->objective,
|
||||
'currency' => $this->currency,
|
||||
'amount_min' => $this->amount_min,
|
||||
'amount_max' => $this->amount_max,
|
||||
'available_percentage' => $this->available_percentage,
|
||||
'published_at' => $this->published_at,
|
||||
'sector' => $this->whenLoaded('sector', fn () => $this->sector?->only(['name', 'slug'])),
|
||||
'province' => $this->province,
|
||||
'district' => $this->district,
|
||||
'locality' => $this->locality,
|
||||
'id' => data_get($this->resource, 'id'),
|
||||
'slug' => data_get($this->resource, 'public_slug'),
|
||||
'title' => data_get($this->resource, 'public_title'),
|
||||
'activity_code' => data_get($this->resource, 'activity_code'),
|
||||
'stage' => data_get($this->resource, 'stage'),
|
||||
'objective' => data_get($this->resource, 'objective'),
|
||||
'currency' => data_get($this->resource, 'currency'),
|
||||
'amount_min' => data_get($this->resource, 'amount_min'),
|
||||
'amount_max' => data_get($this->resource, 'amount_max'),
|
||||
'available_percentage' => data_get($this->resource, 'available_percentage'),
|
||||
'published_at' => data_get($this->resource, 'published_at'),
|
||||
'sector' => $this->whenLoaded('sector', fn () => data_get($this->resource, 'sector')?->only(['name', 'slug'])),
|
||||
'province' => $this->resource->getAttribute('province'),
|
||||
'district' => $this->resource->getAttribute('district'),
|
||||
'locality' => $this->resource->getAttribute('locality'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,31 @@ class RunSimulation implements ShouldQueue
|
||||
}
|
||||
$input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR);
|
||||
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
|
||||
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'indicative_total' => $row->value,
|
||||
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-tax-arithmetic-v1'];
|
||||
$resultId = (string) Str::uuid();
|
||||
$engineVersion = 'portal-arithmetic-v1+simulation-v'.$simulation->version_no;
|
||||
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'scenario_kind' => 'custom_planning', 'indicative_total' => $row->value,
|
||||
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-arithmetic-v1',
|
||||
'contract_version' => 1,
|
||||
'context' => [
|
||||
'reference_date' => substr((string) $scenario->created_at, 0, 10),
|
||||
'currency' => null,
|
||||
'currency_fraction_digits' => null,
|
||||
'scenario_id' => (string) $scenario->id,
|
||||
'scenario_version' => (int) $scenario->version_no,
|
||||
'engine_version' => $engineVersion,
|
||||
'ruleset_version' => null,
|
||||
],
|
||||
'result' => [
|
||||
'status' => 'calculated',
|
||||
'value' => (string) $row->value,
|
||||
'unit' => null,
|
||||
'explanation' => 'Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.',
|
||||
'missing_inputs' => [],
|
||||
'source_ids' => [],
|
||||
]];
|
||||
$json = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
$canonicalResult = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
|
||||
$resultHash = hash('sha256', $canonicalResult);
|
||||
$resultId = (string) Str::uuid();
|
||||
$engineVersion = 'portal-tax-arithmetic-v1+simulation-v'.$simulation->version_no;
|
||||
DB::table('simulation_results')->insert([
|
||||
'id' => $resultId, 'simulation_id' => $simulation->id, 'scenario_id' => $scenario->id, 'engine_version' => $engineVersion,
|
||||
'result_snapshot' => $json, 'result_sha256' => $resultHash, 'created_at' => now(),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class ScanDocumentForMalware implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 150;
|
||||
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
public function __construct(public readonly string $documentId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->documentId;
|
||||
}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [15, 60];
|
||||
}
|
||||
|
||||
public function handle(MalwareScanner $scanner): void
|
||||
{
|
||||
$document = DB::transaction(function (): ?object {
|
||||
$row = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
|
||||
if (! $row || $row->status === 'deleted' || $row->deleted_at !== null) {
|
||||
return null;
|
||||
}
|
||||
if ($row->malware_scan_status === 'clean' && hash_equals(trim($row->sha256), (string) $row->malware_scanned_sha256)) {
|
||||
return null;
|
||||
}
|
||||
if ($row->malware_scan_status === 'infected') {
|
||||
return null;
|
||||
}
|
||||
|
||||
DB::table('documents')->where('id', $this->documentId)->update([
|
||||
'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null,
|
||||
'malware_scan_attempts' => DB::raw('malware_scan_attempts + 1'), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
if (! $document) {
|
||||
return;
|
||||
}
|
||||
|
||||
$startedAt = now();
|
||||
$disk = Storage::disk('local');
|
||||
$root = $disk->path('');
|
||||
$path = $disk->path($document->storage_key);
|
||||
$beforeHash = is_file($path) ? hash_file('sha256', $path) : false;
|
||||
if (! is_string($beforeHash) || ! hash_equals(trim($document->sha256), $beforeHash)) {
|
||||
$this->record($document, [
|
||||
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
|
||||
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
|
||||
], $startedAt, 'inconclusive');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root);
|
||||
|
||||
$afterHash = is_file($path) ? hash_file('sha256', $path) : false;
|
||||
if (! is_string($afterHash) || ! hash_equals(trim($document->sha256), $afterHash)) {
|
||||
$result['result'] = 'inconclusive';
|
||||
$result['reason_code'] = 'file_changed_during_scan';
|
||||
$result['detail'] = null;
|
||||
}
|
||||
$result['file_sha256'] = $afterHash;
|
||||
|
||||
$this->record($document, $result, $startedAt, $result['result']);
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$document = DB::table('documents')->where('id', $this->documentId)->first();
|
||||
if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record($document, [
|
||||
'result' => 'failed', 'reason_code' => 'scanner_unavailable_after_retries', 'engine' => 'ClamAV',
|
||||
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null,
|
||||
], now(), 'failed');
|
||||
}
|
||||
|
||||
private function record(object $document, array $result, \DateTimeInterface $startedAt, string $state): void
|
||||
{
|
||||
DB::transaction(function () use ($document, $result, $startedAt, $state): void {
|
||||
$locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
|
||||
if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256));
|
||||
$finalState = $sameBytes ? $state : 'inconclusive';
|
||||
$reason = $sameBytes ? $result['reason_code'] : 'document_record_changed_during_scan';
|
||||
$release = $finalState === 'clean' && hash_equals(trim($locked->sha256), trim((string) ($result['file_sha256'] ?? '')));
|
||||
$finishedAt = now();
|
||||
$scanId = (string) Str::uuid();
|
||||
|
||||
DB::table('documents')->where('id', $this->documentId)->update([
|
||||
'status' => $release ? 'active' : 'quarantined',
|
||||
'malware_scan_status' => $finalState,
|
||||
'malware_scanned_sha256' => $sameBytes
|
||||
&& hash_equals(trim($locked->sha256), trim((string) ($result['file_sha256'] ?? '')))
|
||||
? trim($locked->sha256)
|
||||
: null,
|
||||
'malware_engine' => $result['engine'] ?? 'ClamAV',
|
||||
'malware_engine_version' => $result['engine_version'] ?? 'unknown',
|
||||
'malware_signatures_at' => $result['signatures_at'] ?? null,
|
||||
'malware_scanned_at' => $finishedAt,
|
||||
'malware_failure_reason' => $reason,
|
||||
'updated_at' => $finishedAt,
|
||||
]);
|
||||
DB::table('document_malware_scans')->insert([
|
||||
'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($locked->sha256),
|
||||
'result' => $finalState, 'reason_code' => $reason,
|
||||
'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown',
|
||||
'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt,
|
||||
'finished_at' => $finishedAt, 'duration_ms' => $result['duration_ms'] ?? null,
|
||||
'file_size_bytes' => $locked->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/** @property int $version_no */
|
||||
class ClusterProject extends DomainRecord {}
|
||||
|
||||
@@ -2,9 +2,23 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string|null $public_slug
|
||||
* @property string|null $public_title
|
||||
* @property string|null $activity_code
|
||||
* @property string|null $stage
|
||||
* @property string|null $objective
|
||||
* @property string|null $currency
|
||||
* @property string|null $amount_min
|
||||
* @property string|null $amount_max
|
||||
* @property string|null $available_percentage
|
||||
* @property CarbonImmutable|null $published_at
|
||||
* @property-read Sector|null $sector
|
||||
*/
|
||||
class ExistingBusiness extends DomainRecord
|
||||
{
|
||||
protected $hidden = ['payload'];
|
||||
|
||||
@@ -5,6 +5,12 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $version_no
|
||||
* @property string $validation_status
|
||||
* @property string $valid_from
|
||||
* @property string|null $valid_until
|
||||
*/
|
||||
class InvestmentLegalSource extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
@@ -2,7 +2,24 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class InvestmentZone extends DomainRecord
|
||||
{
|
||||
protected $casts = ['version_no' => 'integer', 'published_at' => 'datetime'];
|
||||
protected $casts = ['version_no' => 'integer', 'published_at' => 'datetime', 'aliases' => 'array', 'activities' => 'array', 'resources' => 'array', 'historical_territory' => 'array', 'area_components' => 'array', 'source_evidence' => 'array', 'area_value' => 'decimal:4', 'data_as_of' => 'date', 'sources_consulted_at' => 'date'];
|
||||
|
||||
/** @return BelongsTo<GeoLocation, $this> */
|
||||
public function primaryTerritory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GeoLocation::class, 'primary_territory_id');
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<GeoLocation, $this> */
|
||||
public function documentedLocations(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(GeoLocation::class, 'investment_zone_locations', 'zone_id', 'geo_location_id')
|
||||
->withPivot(['id', 'scope_type', 'source_reference', 'legal_source_id', 'valid_from', 'valid_until', 'validation_status', 'editor_id', 'reviewer_id', 'version_no'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array<string, mixed> $payload
|
||||
* @property string $crm_submission_key
|
||||
* @property int|null $interest_request_id
|
||||
* @property string $status
|
||||
* @property string $reference
|
||||
* @property int $version
|
||||
*/
|
||||
class LandAssistanceCase extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'encrypted:array',
|
||||
'version' => 'integer',
|
||||
'support_requested_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ class Opportunity extends Model
|
||||
'expected_impact', 'status', 'is_featured', 'published_at',
|
||||
'last_validated_at',
|
||||
'primary_territory_id', 'territory_version',
|
||||
'geography_scope',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -45,6 +47,12 @@ class Opportunity extends Model
|
||||
return $this->belongsTo(GeoLocation::class, 'primary_territory_id');
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<GeoLocation, $this> */
|
||||
public function additionalTerritories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(GeoLocation::class, 'opportunity_locations', 'opportunity_id', 'geo_location_id');
|
||||
}
|
||||
|
||||
/** @param Builder<Opportunity> $query
|
||||
* @return Builder<Opportunity>
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -14,7 +15,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property string $status
|
||||
* @property int $version_no
|
||||
* @property array<string, mixed> $input_snapshot
|
||||
* @property array<string, mixed> $calculation_snapshot
|
||||
* @property array<string, mixed>|null $result_snapshot
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
class Simulation extends Model
|
||||
{
|
||||
|
||||
@@ -3,20 +3,41 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property int|null $owner_user_id
|
||||
* @property string|null $anonymous_token_hash
|
||||
* @property int $version_no
|
||||
* @property string $create_key_hash
|
||||
* @property string $create_payload_hash
|
||||
* @property string $snapshot_sha256
|
||||
* @property string $personal_snapshot
|
||||
* @property string $economic_snapshot
|
||||
* @property Carbon|null $deleted_at
|
||||
* @property Carbon|null $expires_at
|
||||
* @property string|null $claimed_token_hash
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
class SimulationFormDraft extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
protected $table = 'simulation_form_drafts';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'id', 'owner_user_id', 'anonymous_token_hash', 'version_no', 'create_key_hash', 'create_payload_hash', 'snapshot_sha256',
|
||||
'personal_snapshot', 'economic_snapshot', 'deleted_at',
|
||||
'personal_snapshot', 'economic_snapshot', 'deleted_at', 'expires_at', 'claimed_token_hash',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['version_no' => 'integer', 'deleted_at' => 'immutable_datetime'];
|
||||
return ['version_no' => 'integer', 'deleted_at' => 'immutable_datetime', 'expires_at' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $snapshot */
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property int $version_no
|
||||
* @property string $status
|
||||
* @property mixed $report_snapshot
|
||||
* @property string $report_sha256
|
||||
* @property Carbon|null $created_at
|
||||
*/
|
||||
class SimulationReport extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $rule_code
|
||||
* @property string $component
|
||||
* @property string $scope_key
|
||||
* @property string $status
|
||||
* @property int $author_id
|
||||
* @property int|null $reviewer_id
|
||||
* @property int|null $approver_id
|
||||
* @property Carbon $valid_from
|
||||
* @property Carbon|null $valid_until
|
||||
*/
|
||||
class SimulationTaxRuleVersion extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'rule_definition' => 'array',
|
||||
'parameters' => 'array',
|
||||
'rounding_policy' => 'array',
|
||||
'valid_from' => 'date:Y-m-d',
|
||||
'valid_until' => 'date:Y-m-d',
|
||||
'reviewed_at' => 'immutable_datetime',
|
||||
'approved_at' => 'immutable_datetime',
|
||||
'published_at' => 'immutable_datetime',
|
||||
'suspended_at' => 'immutable_datetime',
|
||||
'archived_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use App\Services\Mne\CompatibleBusinessRepository;
|
||||
use App\Services\Malware\ClamDScanner;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -25,6 +27,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(MalwareScanner::class, ClamDScanner::class);
|
||||
|
||||
$this->app->bind(BusinessRepository::class, CompatibleBusinessRepository::class);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ final class EvidenceRepository
|
||||
->where('s.status', 'approved')->where('s.purpose', 'investment_information')
|
||||
->where('s.valid_until', '>=', now()->toDateString())->where('s.source_date', '<=', now()->toDateString())
|
||||
->where('d.status', 'active')->whereNull('d.deleted_at')->whereIn('d.data_classification', ['public', 'internal'])
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->whereColumn('s.document_version', 'd.version_no')->whereColumn('s.document_checksum', 'd.sha256');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ final class SourceIngestion
|
||||
{
|
||||
return DB::transaction(function () use ($data, $actor): string {
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
if (! $document || $document->status !== 'active' || $document->media_type !== 'text/plain'
|
||||
if (! $document || $document->status !== 'active' || $document->malware_scan_status !== 'clean'
|
||||
|| ! hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)) || $document->media_type !== 'text/plain'
|
||||
|| ! in_array($document->data_classification, ['public', 'internal'], true)) {
|
||||
throw ValidationException::withMessages(['document_id' => 'Documento activo de texto autorizado necessário; fontes confidenciais/restritas não são admitidas.']);
|
||||
}
|
||||
|
||||
@@ -3,37 +3,77 @@
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class BusinessFormSchema
|
||||
{
|
||||
/** @return array<string, array{step:int,pt:string,en:string,type:string,max:int,options?:array<string,string>}> */
|
||||
/** @return array<string, array{step:int,pt:string,en:string,type:string,max:int,options?:array<string,string>,when?:array<string,list<string>>}> */
|
||||
public static function fields(): array
|
||||
{
|
||||
return [
|
||||
'proponent_type' => ['step' => 1, 'pt' => 'Tipo de proponente', 'en' => 'Applicant type', 'type' => 'select', 'max' => 30, 'options' => ['person' => 'Pessoa / Person', 'organization' => 'Organização / Organization']],
|
||||
'proponent_type' => ['step' => 1, 'pt' => 'Quem apresenta a oportunidade?', 'en' => 'Who is presenting the opportunity?', 'type' => 'select', 'max' => 30, 'options' => ['organization' => 'Empresa ou organização / Company or organization', 'sole_trader' => 'Empresário em nome individual / Sole trader', 'person' => 'Pessoa singular / Individual', 'public_entity' => 'Entidade pública / Public entity']],
|
||||
'proponent_name' => ['step' => 1, 'pt' => 'Nome ou denominação', 'en' => 'Name', 'type' => 'text', 'max' => 200],
|
||||
'tax_identifier' => ['step' => 1, 'pt' => 'NUIT ou identificador', 'en' => 'Tax identifier', 'type' => 'text', 'max' => 50],
|
||||
'contact_email' => ['step' => 1, 'pt' => 'Email de contacto', 'en' => 'Contact email', 'type' => 'email', 'max' => 254],
|
||||
'contact_phone' => ['step' => 1, 'pt' => 'Telefone', 'en' => 'Phone', 'type' => 'tel', 'max' => 40],
|
||||
'country' => ['step' => 1, 'pt' => 'País (código de duas letras)', 'en' => 'Country (two-letter code)', 'type' => 'text', 'max' => 2],
|
||||
'country' => ['step' => 1, 'pt' => 'País de origem ou constituição (código ISO de duas letras)', 'en' => 'Country of origin or incorporation (two-letter ISO code)', 'type' => 'text', 'max' => 2],
|
||||
'tax_identifier' => ['step' => 1, 'pt' => 'NUIT ou identificador (interno, quando aplicável)', 'en' => 'NUIT or identifier (internal, when applicable)', 'type' => 'text', 'max' => 50],
|
||||
'contact_name' => ['step' => 1, 'pt' => 'Nome da pessoa de contacto', 'en' => 'Contact person name', 'type' => 'text', 'max' => 200],
|
||||
'contact_email' => ['step' => 1, 'pt' => 'Correio electrónico (indique email ou telefone)', 'en' => 'Email (provide email or phone)', 'type' => 'email', 'max' => 254],
|
||||
'contact_phone' => ['step' => 1, 'pt' => 'Telefone (indique telefone ou email)', 'en' => 'Phone (provide phone or email)', 'type' => 'tel', 'max' => 40],
|
||||
'contact_preference' => ['step' => 1, 'pt' => 'Preferência de contacto', 'en' => 'Contact preference', 'type' => 'select', 'max' => 30, 'options' => ['through_portal' => 'Receber pedidos através da InvestMoz', 'approved_business_contact' => 'Mostrar contacto empresarial aprovado']],
|
||||
'acting_capacity' => ['step' => 2, 'pt' => 'Qualidade em que actua', 'en' => 'Acting capacity', 'type' => 'select', 'max' => 30, 'options' => ['holder' => 'Titular / Holder', 'representative' => 'Representante / Representative']],
|
||||
'declared_holder' => ['step' => 2, 'pt' => 'Titular declarado', 'en' => 'Declared holder', 'type' => 'text', 'max' => 200],
|
||||
'mandate_reference' => ['step' => 2, 'pt' => 'Referência do mandato', 'en' => 'Mandate reference', 'type' => 'text', 'max' => 500],
|
||||
'mandate_valid_until' => ['step' => 2, 'pt' => 'Validade do mandato', 'en' => 'Mandate expiry', 'type' => 'date', 'max' => 10],
|
||||
'declared_holder' => ['step' => 2, 'pt' => 'Entidade ou pessoa representada', 'en' => 'Entity or person represented', 'type' => 'text', 'max' => 200],
|
||||
'mandate_reference' => ['step' => 2, 'pt' => 'Referência do documento de autorização', 'en' => 'Authorization document reference', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'mandate_document_reference' => ['step' => 2, 'pt' => 'Referência interna do documento de autorização (não carregue aqui)', 'en' => 'Internal authorization document reference (do not upload here)', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'mandate_valid_until' => ['step' => 2, 'pt' => 'Validade do mandato', 'en' => 'Mandate expiry', 'type' => 'date', 'max' => 10, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'disclosure_scope' => ['step' => 2, 'pt' => 'Limites da autorização de divulgação', 'en' => 'Limits of disclosure authorization', 'type' => 'textarea', 'max' => 2000, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'legitimacy_declaration' => ['step' => 2, 'pt' => 'Declaro legitimidade para esta submissão', 'en' => 'I declare authority to submit', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim / Yes', 'no' => 'Não / No']],
|
||||
'title' => ['step' => 3, 'pt' => 'Título proposto', 'en' => 'Proposed title', 'type' => 'text', 'max' => 200],
|
||||
'description' => ['step' => 3, 'pt' => 'Descrição reservada', 'en' => 'Private description', 'type' => 'textarea', 'max' => 10000],
|
||||
'opportunity_type' => ['step' => 3, 'pt' => 'Tipo de oportunidade', 'en' => 'Opportunity type', 'type' => 'select', 'max' => 40, 'options' => ['investment_project' => 'Projecto de investimento', 'company_sale' => 'Venda de negócio existente', 'asset_sale' => 'Activo disponível', 'business_partnership' => 'Parceria empresarial', 'financing_request' => 'Procura de financiamento', 'concession_opportunity' => 'Concessão', 'supply_opportunity' => 'Oportunidade de fornecimento']],
|
||||
'description' => ['step' => 3, 'pt' => 'Descrição resumida: o que está disponível, procura e condições principais', 'en' => 'Summary: what is available, sought and key conditions', 'type' => 'textarea', 'max' => 10000],
|
||||
'subsector_activity' => ['step' => 3, 'pt' => 'Subsector e actividade (por designação)', 'en' => 'Subsector and activity (by name)', 'type' => 'text', 'max' => 500],
|
||||
'declared_reason' => ['step' => 3, 'pt' => 'Motivo e objectivo', 'en' => 'Reason and objective', 'type' => 'textarea', 'max' => 2000],
|
||||
'valid_until' => ['step' => 3, 'pt' => 'Validade declarada', 'en' => 'Declared expiry', 'type' => 'date', 'max' => 10],
|
||||
'asking_value' => ['step' => 4, 'pt' => 'Valor pedido', 'en' => 'Asking value', 'type' => 'decimal', 'max' => 24],
|
||||
'currency' => ['step' => 4, 'pt' => 'Moeda (código de três letras)', 'en' => 'Currency (three-letter code)', 'type' => 'text', 'max' => 3],
|
||||
'value_nature' => ['step' => 4, 'pt' => 'Natureza do valor', 'en' => 'Value basis', 'type' => 'select', 'max' => 30, 'options' => ['asking' => 'Pedido / Asking', 'indicative' => 'Indicativo / Indicative', 'appraised' => 'Avaliado, sujeito a prova / Appraised, evidence required']],
|
||||
'project_stage' => ['step' => 3, 'pt' => 'Situação do projecto ou negócio', 'en' => 'Project or business status', 'type' => 'select', 'max' => 40, 'options' => ['idea' => 'Ideia ou conceito', 'study' => 'Em estudo', 'preparation' => 'Em preparação', 'implementation' => 'Em implementação', 'operating' => 'Em funcionamento', 'suspended' => 'Actividade suspensa', 'other' => 'Outra situação']],
|
||||
'project_stage_other' => ['step' => 3, 'pt' => 'Especifique a situação', 'en' => 'Specify the status', 'type' => 'text', 'max' => 200, 'when' => ['project_stage' => ['other']]],
|
||||
'availability' => ['step' => 3, 'pt' => 'Disponibilidade', 'en' => 'Availability', 'type' => 'select', 'max' => 40, 'options' => ['available' => 'Disponível', 'from_date' => 'Disponível a partir de uma data', 'negotiating' => 'Em negociação; aceita manifestações de interesse']],
|
||||
'available_from' => ['step' => 3, 'pt' => 'Disponível a partir de', 'en' => 'Available from', 'type' => 'date', 'max' => 10, 'when' => ['availability' => ['from_date']]],
|
||||
'valid_until' => ['step' => 3, 'pt' => 'Prazo para manifestações de interesse (opcional)', 'en' => 'Interest deadline (optional)', 'type' => 'date', 'max' => 10],
|
||||
'asking_value' => ['step' => 4, 'pt' => 'Valor', 'en' => 'Value', 'type' => 'decimal', 'max' => 24, 'when' => ['value_status' => ['defined', 'indicative']]],
|
||||
'value_status' => ['step' => 4, 'pt' => 'O valor está definido?', 'en' => 'Is the value defined?', 'type' => 'select', 'max' => 30, 'options' => ['defined' => 'Sim', 'indicative' => 'Valor indicativo', 'consultation' => 'Sob consulta', 'unknown' => 'Ainda não definido']],
|
||||
'value_meaning' => ['step' => 4, 'pt' => 'Significado do valor', 'en' => 'Meaning of the value', 'type' => 'select', 'max' => 30, 'options' => ['asking_price' => 'Preço pedido', 'investment_estimate' => 'Investimento estimado', 'financing_sought' => 'Financiamento procurado'], 'when' => ['value_status' => ['defined', 'indicative']]],
|
||||
'value_nature' => ['step' => 4, 'pt' => 'Natureza do valor', 'en' => 'Value basis', 'type' => 'select', 'max' => 30, 'options' => ['asking' => 'Pedido', 'indicative' => 'Indicativo', 'appraised' => 'Avaliado, sujeito a prova'], 'when' => ['value_status' => ['defined', 'indicative']]],
|
||||
'currency' => ['step' => 4, 'pt' => 'Moeda (código de três letras)', 'en' => 'Currency (three-letter code)', 'type' => 'text', 'max' => 3, 'when' => ['value_status' => ['defined', 'indicative']]],
|
||||
'amount_secured' => ['step' => 4, 'pt' => 'Montante assegurado (projectos)', 'en' => 'Secured amount (projects)', 'type' => 'decimal', 'max' => 24, 'when' => ['opportunity_type' => ['investment_project']]],
|
||||
'investment_total' => ['step' => 4, 'pt' => 'Investimento total estimado (projectos)', 'en' => 'Estimated total investment (projects)', 'type' => 'decimal', 'max' => 24, 'when' => ['opportunity_type' => ['investment_project']]],
|
||||
'amount_sought' => ['step' => 4, 'pt' => 'Montante de financiamento procurado', 'en' => 'Financing sought', 'type' => 'decimal', 'max' => 24, 'when' => ['opportunity_type' => ['investment_project', 'financing_request']]],
|
||||
'equity_percentage' => ['step' => 4, 'pt' => 'Participação disponível (%)', 'en' => 'Available equity (%)', 'type' => 'decimal', 'max' => 8],
|
||||
'funds_use' => ['step' => 4, 'pt' => 'Utilização dos fundos', 'en' => 'Use of funds', 'type' => 'textarea', 'max' => 2000],
|
||||
'location_notes' => ['step' => 5, 'pt' => 'Localização declarada (reservada)', 'en' => 'Declared location (private)', 'type' => 'textarea', 'max' => 2000],
|
||||
'asset_deal_kind' => ['step' => 4, 'pt' => 'Tipo de venda', 'en' => 'Type of sale', 'type' => 'select', 'max' => 30, 'options' => ['whole_business' => 'Venda total', 'participation' => 'Participação', 'assets' => 'Activos'], 'when' => ['opportunity_type' => ['company_sale', 'asset_sale']]],
|
||||
'partnership_contribution' => ['step' => 4, 'pt' => 'Contribuição pretendida', 'en' => 'Contribution sought', 'type' => 'select', 'max' => 30, 'options' => ['capital' => 'Capital', 'technology' => 'Tecnologia', 'management' => 'Gestão', 'equipment' => 'Equipamento', 'distribution' => 'Distribuição', 'other' => 'Outra'], 'when' => ['opportunity_type' => ['business_partnership']]],
|
||||
'asset_modality' => ['step' => 4, 'pt' => 'Modalidade do activo', 'en' => 'Asset arrangement', 'type' => 'select', 'max' => 30, 'options' => ['sale' => 'Venda', 'lease' => 'Arrendamento', 'other' => 'Outra'], 'when' => ['opportunity_type' => ['asset_sale']]],
|
||||
'concession_entity' => ['step' => 4, 'pt' => 'Entidade responsável pela concessão', 'en' => 'Concession authority', 'type' => 'text', 'max' => 300, 'when' => ['opportunity_type' => ['concession_opportunity']]],
|
||||
'concession_reference' => ['step' => 4, 'pt' => 'Referência oficial, procedimento e prazo', 'en' => 'Official reference, procedure and deadline', 'type' => 'textarea', 'max' => 2000, 'when' => ['opportunity_type' => ['concession_opportunity']]],
|
||||
'supply_description' => ['step' => 4, 'pt' => 'Bem ou serviço, quantidade/capacidade, destino e prazo', 'en' => 'Good or service, quantity/capacity, destination and timing', 'type' => 'textarea', 'max' => 2000, 'when' => ['opportunity_type' => ['supply_opportunity']]],
|
||||
'conditions' => ['step' => 4, 'pt' => 'Condições adicionais', 'en' => 'Additional conditions', 'type' => 'textarea', 'max' => 4000],
|
||||
'geography_scope' => ['step' => 5, 'pt' => 'Âmbito geográfico', 'en' => 'Geographic scope', 'type' => 'select', 'max' => 30, 'options' => ['specific' => 'Localização específica', 'multiple' => 'Várias localizações', 'national' => 'Âmbito nacional', 'digital' => 'Digital/sem instalação definida']],
|
||||
'province_id' => ['step' => 5, 'pt' => 'Província ou Cidade de Maputo', 'en' => 'Province or Maputo City', 'type' => 'territory', 'max' => 36, 'when' => ['geography_scope' => ['specific', 'multiple']]],
|
||||
'district_id' => ['step' => 5, 'pt' => 'Distrito', 'en' => 'District', 'type' => 'territory', 'max' => 36, 'when' => ['geography_scope' => ['specific', 'multiple']]],
|
||||
'admin_post_id' => ['step' => 5, 'pt' => 'Posto administrativo', 'en' => 'Administrative post', 'type' => 'territory', 'max' => 36, 'when' => ['geography_scope' => ['specific', 'multiple']]],
|
||||
'locality_id' => ['step' => 5, 'pt' => 'Localidade', 'en' => 'Locality', 'type' => 'territory', 'max' => 36, 'when' => ['geography_scope' => ['specific', 'multiple']]],
|
||||
'locations_json' => ['step' => 5, 'pt' => 'Localizações administrativas (várias)', 'en' => 'Administrative locations (multiple)', 'type' => 'location_list', 'max' => 16000, 'when' => ['geography_scope' => ['multiple']]],
|
||||
'location_notes' => ['step' => 5, 'pt' => 'Endereço ou referência (interno)', 'en' => 'Address or reference (internal)', 'type' => 'textarea', 'max' => 2000],
|
||||
'location_unknown' => ['step' => 5, 'pt' => 'Não conheço a localização exacta', 'en' => 'I do not know the exact location', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim', 'no' => 'Não'], 'when' => ['geography_scope' => ['specific', 'multiple']]],
|
||||
'disclosure_precision' => ['step' => 5, 'pt' => 'Precisão de divulgação', 'en' => 'Disclosure precision', 'type' => 'select', 'max' => 30, 'options' => ['exact' => 'Localização exacta (sujeita a aprovação)', 'approximate' => 'Localização aproximada', 'administrative' => 'Apenas território administrativo'], 'when' => ['geography_scope' => ['specific', 'multiple']]],
|
||||
'asset_notes' => ['step' => 6, 'pt' => 'Resumo dos activos e direitos declarados', 'en' => 'Declared assets and rights summary', 'type' => 'textarea', 'max' => 2000],
|
||||
'source_reference' => ['step' => 7, 'pt' => 'Referência da fonte', 'en' => 'Source reference', 'type' => 'textarea', 'max' => 2000],
|
||||
'source_provider' => ['step' => 7, 'pt' => 'Quem forneceu os dados?', 'en' => 'Who supplied the information?', 'type' => 'text', 'max' => 500],
|
||||
'source_date' => ['step' => 7, 'pt' => 'Data da fonte/informação', 'en' => 'Source/information date', 'type' => 'date', 'max' => 10],
|
||||
'official_source_entity' => ['step' => 7, 'pt' => 'Entidade da fonte oficial (se existir)', 'en' => 'Official source authority (if any)', 'type' => 'text', 'max' => 500],
|
||||
'official_source_document' => ['step' => 7, 'pt' => 'Documento oficial associado', 'en' => 'Related official document', 'type' => 'text', 'max' => 500],
|
||||
'official_source_url' => ['step' => 7, 'pt' => 'Ligação da fonte oficial', 'en' => 'Official source link', 'type' => 'url', 'max' => 2000],
|
||||
'official_source_period' => ['step' => 7, 'pt' => 'Período da fonte oficial', 'en' => 'Official source period', 'type' => 'text', 'max' => 200],
|
||||
'document_reference' => ['step' => 7, 'pt' => 'Referências documentais (não são ficheiros validados)', 'en' => 'Document references (not validated files)', 'type' => 'textarea', 'max' => 2000],
|
||||
'confirm_information' => ['step' => 8, 'pt' => 'Confirmo que a informação corresponde ao que conheço e identifiquei estimativas ou pendências.', 'en' => 'I confirm the information reflects what I know and identify estimates or pending items.', 'type' => 'checkbox', 'max' => 3, 'options' => ['yes' => 'yes']],
|
||||
'confirm_disclosure_authority' => ['step' => 8, 'pt' => 'Confirmo que tenho autorização para apresentar a oportunidade e divulgar os conteúdos seleccionados.', 'en' => 'I confirm I am authorized to submit the opportunity and disclose selected content.', 'type' => 'checkbox', 'max' => 3, 'options' => ['yes' => 'yes']],
|
||||
'confirm_no_publication_guarantee' => ['step' => 8, 'pt' => 'Compreendo que a submissão não constitui publicação, aprovação oficial ou garantia de investimento.', 'en' => 'I understand submission is not publication, official approval or an investment guarantee.', 'type' => 'checkbox', 'max' => 3, 'options' => ['yes' => 'yes']],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,34 +87,108 @@ final class BusinessFormSchema
|
||||
$rules['payload.'.$key][] = 'in:'.implode(',', array_keys($field['options']));
|
||||
}
|
||||
}
|
||||
$rules['payload.contact_email'][] = 'nullable';
|
||||
$rules['payload.contact_email'][] = 'email';
|
||||
$rules['payload.country'][] = 'regex:/^[A-Z]{2}$/';
|
||||
$rules['payload.currency'][] = 'regex:/^[A-Z]{3}$/';
|
||||
$rules['payload.asking_value'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
|
||||
$rules['payload.amount_secured'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
|
||||
$rules['payload.investment_total'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
|
||||
$rules['payload.amount_sought'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
|
||||
$rules['payload.source_date'][] = 'date_format:Y-m-d';
|
||||
$rules['payload.official_source_url'][] = 'url';
|
||||
$rules['payload.equity_percentage'][] = 'numeric';
|
||||
$rules['payload.equity_percentage'][] = 'between:0,100';
|
||||
foreach (['valid_until', 'mandate_valid_until'] as $date) {
|
||||
$rules['payload.'.$date][] = 'date_format:Y-m-d';
|
||||
}
|
||||
$rules['payload.wizard_version'] = ['nullable', 'in:1'];
|
||||
$rules['payload.wizard_version'] = ['nullable', 'in:1,2'];
|
||||
$rules['payload.wizard_step'] = ['nullable', 'integer', 'between:1,8'];
|
||||
$rules['payload.locations_json'][] = 'json';
|
||||
$rules['payload.title'] = ['required', 'string', 'max:200'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function validateSubmission(array $payload): void
|
||||
public static function validateSubmission(array $payload, ?int $sectorId, string $modality): void
|
||||
{
|
||||
if (($payload['opportunity_type'] ?? null) !== $modality) {
|
||||
throw ValidationException::withMessages(['opportunity_type' => 'O tipo da oportunidade não corresponde à classificação guardada.']);
|
||||
}
|
||||
|
||||
$rules = [];
|
||||
foreach (['proponent_type', 'proponent_name', 'tax_identifier', 'contact_email', 'country', 'acting_capacity', 'declared_holder', 'location_notes'] as $field) {
|
||||
foreach (['proponent_type', 'proponent_name', 'contact_name', 'country', 'acting_capacity', 'title', 'opportunity_type', 'description', 'project_stage', 'availability', 'geography_scope', 'source_reference', 'source_provider', 'source_date'] as $field) {
|
||||
$rules[$field] = ['required'];
|
||||
}
|
||||
$rules['sector_id'] = ['required'];
|
||||
$rules['contact_email'] = ['required_without:contact_phone', 'nullable', 'email'];
|
||||
$rules['contact_phone'] = ['required_without:contact_email', 'nullable', 'string', 'regex:/^[+0-9() .-]{7,40}$/'];
|
||||
$rules['legitimacy_declaration'] = ['required', 'in:yes'];
|
||||
foreach (['confirm_information', 'confirm_disclosure_authority', 'confirm_no_publication_guarantee'] as $confirmation) {
|
||||
$rules[$confirmation] = ['required', 'in:yes'];
|
||||
}
|
||||
$rules['mandate_reference'] = ['required_if:acting_capacity,representative'];
|
||||
$rules['mandate_valid_until'] = ['required_if:acting_capacity,representative', 'nullable', 'date_format:Y-m-d', 'after_or_equal:today'];
|
||||
$rules['currency'] = ['required_with:asking_value'];
|
||||
$rules['value_nature'] = ['required_with:asking_value'];
|
||||
Validator::make($payload, $rules)->validate();
|
||||
$rules['declared_holder'] = ['required_if:acting_capacity,representative'];
|
||||
$rules['disclosure_scope'] = ['required_if:acting_capacity,representative'];
|
||||
$rules['mandate_valid_until'] = ['nullable', 'date_format:Y-m-d', 'after_or_equal:today'];
|
||||
$rules['mandate_document_reference'] = ['required_if:acting_capacity,representative'];
|
||||
$rules['project_stage_other'] = ['required_if:project_stage,other'];
|
||||
$rules['available_from'] = ['required_if:availability,from_date', 'nullable', 'date_format:Y-m-d', 'after_or_equal:today'];
|
||||
$rules['value_status'] = ['required', 'in:defined,indicative,consultation,unknown'];
|
||||
$rules['province_id'] = ['required_if:geography_scope,specific', 'nullable', 'uuid'];
|
||||
foreach (['district_id', 'admin_post_id', 'locality_id'] as $locationField) {
|
||||
$rules[$locationField] = ['nullable', 'uuid'];
|
||||
}
|
||||
$rules['locations_json'] = ['required_if:geography_scope,multiple', 'nullable', 'json', 'max:16000'];
|
||||
$rules['asking_value'] = ['required_if:value_status,defined,indicative', 'nullable', 'numeric', 'min:0'];
|
||||
$rules['currency'] = ['required_if:value_status,defined,indicative', 'nullable', 'regex:/^[A-Z]{3}$/'];
|
||||
$rules['value_meaning'] = ['required_if:value_status,defined,indicative'];
|
||||
$rules['amount_secured'] = ['nullable', 'numeric', 'min:0'];
|
||||
$rules['investment_total'] = ['required_if:opportunity_type,investment_project', 'nullable', 'numeric', 'gt:0'];
|
||||
$rules['amount_sought'] = ['nullable', 'numeric', 'min:0'];
|
||||
$rules['equity_percentage'] = ['nullable', 'numeric', 'between:0,100'];
|
||||
$rules['asset_deal_kind'] = ['required_if:opportunity_type,company_sale,asset_sale'];
|
||||
$rules['equity_percentage'] = ['required_if:asset_deal_kind,participation', 'nullable', 'numeric', 'between:0,100'];
|
||||
$rules['partnership_contribution'] = ['required_if:opportunity_type,business_partnership'];
|
||||
$rules['amount_sought'] = ['required_if:opportunity_type,financing_request', 'nullable', 'numeric', 'min:0'];
|
||||
$rules['asset_modality'] = ['required_if:opportunity_type,asset_sale'];
|
||||
$rules['concession_entity'] = ['required_if:opportunity_type,concession_opportunity'];
|
||||
$rules['concession_reference'] = ['required_if:opportunity_type,concession_opportunity'];
|
||||
$rules['supply_description'] = ['required_if:opportunity_type,supply_opportunity'];
|
||||
Validator::make([...$payload, 'sector_id' => $sectorId, 'modality' => $modality], $rules)->validate();
|
||||
|
||||
$geography = app(InvestmentGeography::class);
|
||||
if (($payload['geography_scope'] ?? null) === 'specific') {
|
||||
$geography->selection(array_intersect_key($payload, array_flip(InvestmentGeography::FIELDS)));
|
||||
} elseif (($payload['geography_scope'] ?? null) === 'multiple') {
|
||||
$locations = json_decode($payload['locations_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
Validator::make(['locations' => $locations], [
|
||||
'locations' => ['required', 'array', 'min:2', 'max:20'],
|
||||
'locations.*' => ['required', 'array:province_id,district_id,admin_post_id,locality_id'],
|
||||
'locations.*.province_id' => ['required', 'uuid'],
|
||||
'locations.*.district_id' => ['nullable', 'uuid'],
|
||||
'locations.*.admin_post_id' => ['nullable', 'uuid'],
|
||||
'locations.*.locality_id' => ['nullable', 'uuid'],
|
||||
])->validate();
|
||||
|
||||
$paths = [];
|
||||
foreach ($locations as $index => $location) {
|
||||
$path = array_map(fn ($node) => (string) $node->id, $geography->selection($location));
|
||||
foreach ($paths as $existingPath) {
|
||||
$sharedPrefixLength = 0;
|
||||
while (isset($path[$sharedPrefixLength], $existingPath[$sharedPrefixLength])
|
||||
&& $path[$sharedPrefixLength] === $existingPath[$sharedPrefixLength]) {
|
||||
$sharedPrefixLength++;
|
||||
}
|
||||
if ($sharedPrefixLength === min(count($path), count($existingPath))) {
|
||||
throw ValidationException::withMessages([
|
||||
'locations_json' => 'As localizações devem ser distintas e não podem repetir ou conter o mesmo percurso administrativo.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ class ConvertInterestToLead
|
||||
{
|
||||
return DB::transaction(function () use ($interest, $data, $actor) {
|
||||
$interest = InterestRequest::lockForUpdate()->findOrFail($interest->id);
|
||||
$contactConsentIsCurrent = DB::table('consents')->where('interest_request_id', $interest->id)
|
||||
->where('subject_ref', $interest->reference)->where('purpose', 'respond_to_interest')
|
||||
->where('status', 'granted')->whereNotNull('granted_at')->where('granted_at', '<=', now())
|
||||
->whereNull('withdrawn_at')->whereNull('deleted_at')->lockForUpdate()->exists();
|
||||
if (! $interest->privacy_consent || ! $contactConsentIsCurrent) {
|
||||
throw ValidationException::withMessages(['investor_id' => 'O pedido não tem consentimento de contacto activo.']);
|
||||
}
|
||||
$existing = Lead::withTrashed()->where('interest_request_id', $interest->id)->first();
|
||||
if ($existing) {
|
||||
if ($existing->trashed()) {
|
||||
@@ -25,8 +32,8 @@ class ConvertInterestToLead
|
||||
|
||||
return $existing;
|
||||
}
|
||||
if (! $interest->privacy_consent) {
|
||||
throw ValidationException::withMessages(['investor_id' => 'O pedido não tem consentimento de contacto registado.']);
|
||||
if (! is_string($interest->email) || trim($interest->email) === '') {
|
||||
throw ValidationException::withMessages(['investor_id' => 'Confirme um email antes de converter este pedido telefonico num contacto/lead CRM.']);
|
||||
}
|
||||
$email = strtolower(trim($interest->email));
|
||||
$journeyInvestor = DB::table('investor_journeys')->where('interest_request_id', $interest->id)->whereNull('deleted_at')->value('investor_id');
|
||||
|
||||
@@ -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'];
|
||||
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'];
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, array $input, ?string $id = null): ExistingBusiness
|
||||
@@ -47,9 +47,6 @@ final class ExistingBusinessDrafts
|
||||
return DB::transaction(function () use ($actor, $id, $data) {
|
||||
if ($id) {
|
||||
$business = app(BusinessRepository::class)->find($id, true);
|
||||
if (($business->payload['wizard_version'] ?? null) === '1') {
|
||||
$data['payload']['wizard_version'] = '1';
|
||||
}
|
||||
Gate::forUser($actor)->authorize('update', $business);
|
||||
abort_unless($business->version_no === $data['version_no'], 409, 'O rascunho foi alterado. Recarregue antes de gravar.');
|
||||
$business->version_no++;
|
||||
|
||||
@@ -65,8 +65,8 @@ final class ExistingBusinessIntake
|
||||
];
|
||||
abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.');
|
||||
if ($action === 'submitted') {
|
||||
if (($business->payload['wizard_version'] ?? null) === '1') {
|
||||
BusinessFormSchema::validateSubmission($business->payload);
|
||||
if (($business->payload['wizard_version'] ?? null) === '2') {
|
||||
BusinessFormSchema::validateSubmission($business->payload, $business->sector_id, $business->modality);
|
||||
$requiredKind = ['asset_sale' => 'asset', 'asset_lease' => 'asset', 'real_estate' => 'property',
|
||||
'agricultural_investment' => 'agricultural_investment', 'land_right' => 'land_right',
|
||||
'mining_license' => 'license', 'forestry_license' => 'license', 'capital_increase' => 'capital_request',
|
||||
@@ -78,11 +78,40 @@ final class ExistingBusinessIntake
|
||||
'confirmation_code' => ['required', Rule::in([$business->code])],
|
||||
'sector_id' => ['required', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
|
||||
])->validate();
|
||||
} elseif (($business->payload['wizard_version'] ?? null) === '1') {
|
||||
Validator::make($business->payload, [
|
||||
'proponent_type' => ['required'], 'proponent_name' => ['required'], 'tax_identifier' => ['required'],
|
||||
'contact_email' => ['required', 'email'], 'country' => ['required', 'regex:/^[A-Z]{2}$/'],
|
||||
'acting_capacity' => ['required'], 'declared_holder' => ['required'], 'location_notes' => ['required'],
|
||||
'legitimacy_declaration' => ['required', 'in:yes'],
|
||||
'mandate_reference' => ['required_if:acting_capacity,representative'],
|
||||
'mandate_valid_until' => ['required_if:acting_capacity,representative', 'nullable', 'date_format:Y-m-d', 'after_or_equal:today'],
|
||||
])->validate();
|
||||
Validator::make(['confirmation_code' => $confirmationCode], [
|
||||
'confirmation_code' => ['required', Rule::in([$business->code])],
|
||||
])->validate();
|
||||
}
|
||||
Validator::make([...$business->payload, 'consent' => $consent], [
|
||||
'description' => ['required', 'string', 'min:30'], 'source_reference' => ['required', 'string', 'min:10'],
|
||||
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], 'consent' => ['accepted'],
|
||||
])->validate();
|
||||
if (($business->payload['wizard_version'] ?? null) === '1') {
|
||||
$requiredKind = ['asset_sale' => 'asset', 'asset_lease' => 'asset', 'real_estate' => 'property',
|
||||
'agricultural_investment' => 'agricultural_investment', 'land_right' => 'land_right',
|
||||
'mining_license' => 'license', 'forestry_license' => 'license', 'capital_increase' => 'capital_request',
|
||||
'participation_sale' => 'participation'][$business->modality] ?? null;
|
||||
if ($requiredKind !== null && ! ExistingBusinessObject::where('business_id', $business->id)->where('kind', $requiredKind)->exists()) {
|
||||
throw ValidationException::withMessages(['objects' => 'Declare the applicable object in step 6: '.$requiredKind]);
|
||||
}
|
||||
}
|
||||
$submissionRules = [
|
||||
'description' => ['required', 'string', 'min:30'],
|
||||
'source_reference' => ['required', 'string', 'min:10'],
|
||||
'consent' => ['accepted'],
|
||||
// Deadlines are optional for newer and legacy payloads, but
|
||||
// an explicitly supplied deadline must never be expired.
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:today'],
|
||||
];
|
||||
if (($business->payload['wizard_version'] ?? null) === '1') {
|
||||
$submissionRules['valid_until'][0] = 'required';
|
||||
}
|
||||
Validator::make([...$business->payload, 'consent' => $consent], $submissionRules)->validate();
|
||||
}
|
||||
if ($action === OpportunityStatus::Approved->value) {
|
||||
$levelOneActor = DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
|
||||
|
||||
@@ -21,6 +21,8 @@ final class ExistingBusinessObjects
|
||||
'declared_holder' => ['nullable', 'string', 'max:500'],
|
||||
'document_reference' => ['nullable', 'string', 'max:1000'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'resource_status' => ['nullable', 'in:available,contracted,planned,unconfirmed'],
|
||||
'competent_entity' => ['nullable', 'string', 'max:500'],
|
||||
'purpose' => ['nullable', 'string', 'max:2000'],
|
||||
'location_description' => ['nullable', 'string', 'max:2000'],
|
||||
|
||||
@@ -18,7 +18,9 @@ final class InterestSubmission
|
||||
{
|
||||
$canonical = array_replace(['phone' => null, 'opportunityReference' => null, 'existingBusinessId' => null,
|
||||
'modality' => null, 'investmentRange' => null, 'shareWithProponent' => false], $payload);
|
||||
$canonical['email'] = strtolower(trim($canonical['email']));
|
||||
$canonical['email'] = isset($canonical['email']) && trim((string) $canonical['email']) !== ''
|
||||
? strtolower(trim((string) $canonical['email']))
|
||||
: null;
|
||||
$canonical['fullName'] = trim($canonical['fullName']);
|
||||
$canonical['message'] = trim($canonical['message']);
|
||||
$canonical['privacyConsent'] = true; // FormRequest requires acceptance.
|
||||
|
||||
@@ -19,6 +19,7 @@ class InvestmentCatalogue
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
|
||||
'opportunity_id' => ['nullable', 'integer', 'min:1'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
'include_national' => ['nullable', 'boolean'],
|
||||
'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
|
||||
'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'],
|
||||
'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'],
|
||||
@@ -46,9 +47,19 @@ class InvestmentCatalogue
|
||||
$query->whereKey($filters['opportunity_id']);
|
||||
}
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
$includeNational = filter_var($filters['include_national'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if ($path !== []) {
|
||||
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->whereIn('primary_territory_id', $ids);
|
||||
$query->where(function ($territory) use ($ids, $includeNational): void {
|
||||
$territory->whereIn('opportunities.primary_territory_id', $ids)
|
||||
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
|
||||
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
|
||||
if ($includeNational) {
|
||||
$territory->orWhere('opportunities.geography_scope', 'national');
|
||||
}
|
||||
});
|
||||
} elseif (! $includeNational) {
|
||||
$query->where('opportunities.geography_scope', '!=', 'national');
|
||||
}
|
||||
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
|
||||
$query->whereIn('opportunities.id', app(InvestmentZones::class)->links()->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id')
|
||||
@@ -83,8 +94,20 @@ class InvestmentCatalogue
|
||||
}
|
||||
foreach (self::RELATIONS as $relation => [$foreign, $table]) {
|
||||
if (! empty($filters[$foreign])) {
|
||||
$locationIds = $relation === 'locations'
|
||||
? app(InvestmentGeography::class)->descendants((string) $filters[$foreign])
|
||||
: null;
|
||||
if ($relation === 'locations') {
|
||||
$query->where(fn ($matches) => $matches->whereIn('opportunities.primary_territory_id', $locationIds ?? [])
|
||||
->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link')
|
||||
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
|
||||
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
|
||||
->whereNull('target.deleted_at')->where('target.data_classification', 'public')));
|
||||
|
||||
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')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at')
|
||||
->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))));
|
||||
}
|
||||
@@ -93,8 +116,10 @@ class InvestmentCatalogue
|
||||
$query->where(fn ($q) => $q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('is_education', true))
|
||||
->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('education_investment_proposals')->whereColumn('opportunity_id', 'opportunities.id')->whereNull('deleted_at')));
|
||||
}
|
||||
foreach ($filters['facets'] ?? [] as $option) {
|
||||
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_facets')->whereColumn('opportunity_id', 'opportunities.id')->where('option_id', $option));
|
||||
$facetGroups = DB::table('investment_reference_options')->whereIn('id', $filters['facets'] ?? [])->get(['id', 'group'])->groupBy('group');
|
||||
foreach ($facetGroups as $options) {
|
||||
$optionIds = $options->pluck('id')->all();
|
||||
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_facets')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('option_id', $optionIds));
|
||||
}
|
||||
foreach (['min_amount' => '>=', 'max_amount' => '<='] as $field => $operator) {
|
||||
if (isset($filters[$field])) {
|
||||
@@ -118,7 +143,7 @@ class InvestmentCatalogue
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function references(bool $management = false): array
|
||||
public function references(bool $management = false, array $territoryFilters = []): array
|
||||
{
|
||||
$result = ['options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
|
||||
'sectors' => DB::table('sectors')->where('is_active', true)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'slug'])];
|
||||
@@ -134,7 +159,11 @@ class InvestmentCatalogue
|
||||
$result[$relation] = $query->orderBy('name')->get(['id', 'name']);
|
||||
}
|
||||
|
||||
$result['zones'] = app(InvestmentZones::class)->visible()->orderBy('name')->get(['id', 'name', 'category']);
|
||||
$zones = app(InvestmentZones::class)->visible();
|
||||
if (! $management && $territoryFilters !== []) {
|
||||
$zones = app(InvestmentZones::class)->search($territoryFilters);
|
||||
}
|
||||
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -10,6 +11,44 @@ final class InvestmentGeography
|
||||
{
|
||||
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
|
||||
|
||||
/**
|
||||
* Restrict published dossier searches using direct territories and public, validated structure links.
|
||||
* @param EloquentBuilder<\App\Models\InvestmentDossier> $query
|
||||
* @param list<string> $territoryIds
|
||||
* @return EloquentBuilder<\App\Models\InvestmentDossier>
|
||||
*/
|
||||
public function constrainDossiersToTerritories(EloquentBuilder $query, array $territoryIds): EloquentBuilder
|
||||
{
|
||||
return $query->where(function ($matches) use ($territoryIds): void {
|
||||
$matches->whereIn('project_id', DB::table('projects')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id'))
|
||||
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as pl')
|
||||
->whereColumn('pl.project_id', 'investment_dossiers.project_id')->whereNull('pl.deleted_at')
|
||||
->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $territoryIds))
|
||||
->orWhereIn('cluster_id', DB::table('economic_clusters')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id'))
|
||||
->orWhereIn('corridor_id', DB::table('development_corridors')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id'))
|
||||
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('corridor_locations as cl')
|
||||
->whereColumn('cl.corridor_id', 'investment_dossiers.corridor_id')->whereNull('cl.deleted_at')
|
||||
->where('cl.data_classification', 'public')->whereIn('cl.geo_location_id', $territoryIds));
|
||||
|
||||
foreach ([['cluster_projects', 'cluster_id'], ['corridor_projects', 'corridor_id']] as [$linkTable, $ownerColumn]) {
|
||||
$matches->orWhereExists(fn ($links) => $links->selectRaw('1')->from($linkTable.' as territorial_link')
|
||||
->join('projects as linked_project', 'linked_project.id', '=', 'territorial_link.project_id')
|
||||
->whereColumn('territorial_link.'.$ownerColumn, 'investment_dossiers.'.$ownerColumn)
|
||||
->whereNull('territorial_link.deleted_at')->where('territorial_link.data_classification', 'public')
|
||||
->where('territorial_link.validation_status', 'validated')
|
||||
->whereDate('territorial_link.valid_from', '<=', today()->toDateString())
|
||||
->where(fn ($validity) => $validity->whereNull('territorial_link.valid_until')->orWhereDate('territorial_link.valid_until', '>=', today()->toDateString()))
|
||||
->whereNull('linked_project.deleted_at')->where('linked_project.status', '!=', 'draft')
|
||||
->where(function ($projectArea) use ($territoryIds): void {
|
||||
$projectArea->whereIn('linked_project.primary_territory_id', $territoryIds)
|
||||
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as linked_location')
|
||||
->whereColumn('linked_location.project_id', 'linked_project.id')->whereNull('linked_location.deleted_at')
|
||||
->where('linked_location.data_classification', 'public')->whereIn('linked_location.geo_location_id', $territoryIds));
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function publicReferences(): Builder
|
||||
{
|
||||
return DB::table('geo_locations')->whereNull('deleted_at')->where('data_classification', 'public')
|
||||
@@ -33,6 +72,73 @@ final class InvestmentGeography
|
||||
: $this->publicReferences();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload
|
||||
* @return list<list<string>>
|
||||
*/
|
||||
public function displayPaths(array $payload): array
|
||||
{
|
||||
$locations = [];
|
||||
if (($payload['geography_scope'] ?? null) === 'multiple') {
|
||||
$decoded = json_decode((string) ($payload['locations_json'] ?? ''), true);
|
||||
$locations = is_array($decoded) ? $decoded : [];
|
||||
} elseif (($payload['geography_scope'] ?? null) === 'specific') {
|
||||
$locations = [array_intersect_key($payload, array_flip(array_keys(self::FIELDS)))];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
$leafIds = [];
|
||||
foreach ($locations as $location) {
|
||||
$leafIds[] = is_array($location)
|
||||
? ($location['locality_id'] ?? $location['admin_post_id'] ?? $location['district_id'] ?? $location['province_id'] ?? null)
|
||||
: null;
|
||||
}
|
||||
$selected = array_filter($leafIds, fn ($id) => is_string($id) && $id !== '');
|
||||
if (! $selected) {
|
||||
return array_fill(0, count($locations), []);
|
||||
}
|
||||
|
||||
$values = [];
|
||||
$bindings = [];
|
||||
foreach ($selected as $index => $id) {
|
||||
$values[] = '(?, ?::uuid)';
|
||||
$bindings[] = $index;
|
||||
$bindings[] = $id;
|
||||
}
|
||||
$rows = DB::select('WITH RECURSIVE selected(location_index, id) AS (VALUES '.implode(',', $values).'),
|
||||
tree AS (
|
||||
SELECT s.location_index, g.id, g.name, g.location_type, g.parent_id, ARRAY[g.id] AS visited, 0 AS depth
|
||||
FROM selected s JOIN geo_locations g ON g.id=s.id
|
||||
UNION ALL
|
||||
SELECT tree.location_index, parent.id, parent.name, parent.location_type, parent.parent_id,
|
||||
tree.visited||parent.id, tree.depth+1
|
||||
FROM tree JOIN geo_locations parent ON parent.id=tree.parent_id
|
||||
WHERE tree.depth<4 AND NOT parent.id=ANY(tree.visited)
|
||||
)
|
||||
SELECT location_index, id, name, location_type FROM tree ORDER BY location_index, depth DESC', $bindings);
|
||||
$nodeIds = array_values(array_unique(array_map(fn ($row) => (string) $row->id, $rows)));
|
||||
$trustedIds = $this->publicReferences()->whereIn('id', $nodeIds)->pluck('id')->map(fn ($id) => (string) $id)->all();
|
||||
$trusted = array_fill_keys($trustedIds, true);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$grouped[(int) $row->location_index][] = $row;
|
||||
}
|
||||
|
||||
$display = [];
|
||||
foreach ($locations as $index => $_location) {
|
||||
$path = $grouped[$index] ?? [];
|
||||
if (! $path || array_filter($path, fn ($node) => ! isset($trusted[(string) $node->id]))) {
|
||||
$display[] = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
$display[] = array_values(array_map(fn ($node) => (string) $node->name,
|
||||
array_filter($path, fn ($node) => $node->location_type !== 'country')));
|
||||
}
|
||||
|
||||
return $display;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return list<\stdClass>
|
||||
*/
|
||||
@@ -59,7 +165,7 @@ final class InvestmentGeography
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function descendants(string $id): array
|
||||
public function descendants(string $id, bool $includeNonPublic = false): array
|
||||
{
|
||||
$rows = DB::select('WITH RECURSIVE tree AS (
|
||||
SELECT id, ARRAY[id] AS visited FROM geo_locations WHERE id=? AND deleted_at IS NULL
|
||||
@@ -67,7 +173,11 @@ final class InvestmentGeography
|
||||
WHERE g.deleted_at IS NULL AND NOT g.id=ANY(tree.visited) AND cardinality(tree.visited)<5
|
||||
) SELECT id FROM tree', [$id]);
|
||||
|
||||
return array_values($this->publicReferences()->whereIn('id', array_map(fn ($row) => $row->id, $rows))->pluck('id')->map(fn ($id) => (string) $id)->all());
|
||||
$query = $includeNonPublic
|
||||
? DB::table('geo_locations')->whereNull('deleted_at')
|
||||
: $this->publicReferences();
|
||||
|
||||
return array_values($query->whereIn('id', array_map(fn ($row) => $row->id, $rows))->pluck('id')->map(fn ($id) => (string) $id)->all());
|
||||
}
|
||||
|
||||
/** @return list<\stdClass> */
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\InvestmentZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InvestmentZones
|
||||
@@ -25,12 +26,61 @@ class InvestmentZones
|
||||
/** @return Builder<InvestmentZone> */
|
||||
public function visible(): Builder
|
||||
{
|
||||
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
|
||||
return InvestmentZone::query()->where('publication_status', 'published')->where('validation_status', 'validated')
|
||||
->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())));
|
||||
}
|
||||
|
||||
return InvestmentZone::query()->where('publication_status', 'published')->where('validation_status', 'validated')
|
||||
->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())))
|
||||
->whereExists(fn ($q) => $q->selectRaw('1')->from('organizations')->whereColumn('organizations.id', 'investment_zones.operator_id')->whereNull('organizations.deleted_at'));
|
||||
->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://%')");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $filters */
|
||||
public function search(array $filters, bool $management = false): Builder
|
||||
{
|
||||
$query = $management ? InvestmentZone::query() : $this->visible();
|
||||
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
|
||||
unset($filters['physical_category'], $filters['legal_regime'], $filters['development_status'], $filters['availability_status']);
|
||||
}
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->where(function ($matches) use ($territoryIds, $management): void {
|
||||
$matches->whereIn('primary_territory_id', $territoryIds)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('investment_zone_locations as zl')
|
||||
->whereColumn('zl.zone_id', 'investment_zones.id')->whereIn('zl.geo_location_id', $territoryIds)
|
||||
->where('zl.validation_status', 'validated')->where(fn ($valid) => $valid->whereNull('zl.valid_from')->orWhereDate('zl.valid_from', '<=', today()))
|
||||
->where(fn ($valid) => $valid->whereNull('zl.valid_until')->orWhereDate('zl.valid_until', '>=', today())));
|
||||
if (! $management) {
|
||||
$matches->orWhereExists(fn ($geometry) => $geometry->selectRaw('1')->from('investment_zone_geometries as zg')
|
||||
->join('geo_geometries as shape', 'shape.id', '=', 'zg.geometry_id')
|
||||
->whereColumn('zg.zone_id', 'investment_zones.id')->where('zg.publication_authorized', true)
|
||||
->where('zg.validation_status', 'validated')->whereNull('shape.deleted_at')
|
||||
->where('shape.data_classification', 'public')->whereIn('shape.geo_location_id', $territoryIds));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query->when($filters['category'] ?? null, fn ($q, $value) => $q->where('category', $value))
|
||||
->when($filters['physical_category'] ?? null, fn ($q, $value) => $q->where('physical_category', $value))
|
||||
->when($filters['legal_regime'] ?? null, fn ($q, $value) => $q->where('legal_regime', $value))
|
||||
->when($filters['development_status'] ?? null, fn ($q, $value) => $q->where('development_status', $value))
|
||||
->when($filters['availability_status'] ?? null, fn ($q, $value) => $q->where('availability_status', $value))
|
||||
->when($filters['activity'] ?? null, fn ($q, $value) => $q->whereRaw('activities @> ?::jsonb', [json_encode([['name' => $value]], JSON_THROW_ON_ERROR)]))
|
||||
->when($filters['q'] ?? null, fn ($q, $value) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$value.'%')->orWhere('code', 'ilike', '%'.$value.'%')));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
@@ -40,9 +90,18 @@ class InvestmentZones
|
||||
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
|
||||
abort_if($id && (int) $zone->version_no !== (int) $data['version_no'], 409, 'Versão alterada. Recarregue antes de guardar.');
|
||||
$version = $id ? (int) $zone->version_no + 1 : 1;
|
||||
unset($data['version_no']);
|
||||
$locations = $data['locations'] ?? [];
|
||||
unset($data['locations'], $data['version_no']);
|
||||
$zone->fill([...$data, 'editor_id' => $actor, 'reviewer_id' => null, 'published_at' => null,
|
||||
'version_no' => $version, 'publication_status' => 'draft', 'validation_status' => 'pending'])->save();
|
||||
foreach ($locations as $location) {
|
||||
DB::table('investment_zone_locations')->insertOrIgnore([
|
||||
'id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'geo_location_id' => $location['geo_location_id'],
|
||||
'scope_type' => $location['scope_type'] ?? 'administrative', 'source_reference' => $location['source_reference'],
|
||||
'legal_source_id' => $location['legal_source_id'] ?? null, 'validation_status' => 'pending',
|
||||
'editor_id' => $actor, 'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
$this->snapshot($zone, $actor, 'saved');
|
||||
|
||||
return $zone;
|
||||
@@ -56,11 +115,26 @@ class InvestmentZones
|
||||
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();
|
||||
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.');
|
||||
abort_unless($zone->category !== 'pending' && $zone->operator_id && trim((string) $zone->name_en) !== ''
|
||||
&& trim((string) $zone->description) !== '' && trim((string) $zone->description_en) !== '', 422, 'Categoria, operador e conteúdo PT/EN são obrigatórios.');
|
||||
abort_unless(DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')->exists(), 422, 'Operador indisponível.');
|
||||
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.');
|
||||
} 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($zone->category !== 'pending' && trim((string) $zone->name) !== ''
|
||||
&& trim((string) $zone->description) !== '', 422, 'Identidade, categoria e descrição são obrigatórias.');
|
||||
abort_unless($zone->physical_category && $zone->physical_category !== 'unknown', 422, 'Categoria física por confirmar.');
|
||||
if (in_array($zone->category, ['zee', 'zfi'], true)) {
|
||||
abort_unless($zone->legal_regime === $zone->category, 422, 'O regime jurídico da ZEE/ZFI deve estar confirmado.');
|
||||
}
|
||||
$publicLocation = $zone->primary_territory_id
|
||||
&& app(InvestmentGeography::class)->publicReferences()->where('id', $zone->primary_territory_id)->exists();
|
||||
$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.');
|
||||
$zone->fill(['version_no' => $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published',
|
||||
'validation_status' => 'validated', 'published_at' => now()])->save();
|
||||
$this->snapshot($zone, $actor, 'published');
|
||||
@@ -81,6 +155,30 @@ class InvestmentZones
|
||||
});
|
||||
}
|
||||
|
||||
public function validateLocation(string $id, int $version, int $actor): object
|
||||
{
|
||||
return DB::transaction(function () use ($id, $version, $actor) {
|
||||
$location = DB::table('investment_zone_locations')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($location, 404);
|
||||
abort_if((int) $location->version_no !== $version, 409, 'Version changed.');
|
||||
abort_if((int) $location->editor_id === $actor, 403, 'Independent review required.');
|
||||
abort_unless($location->validation_status === 'pending', 422, 'Territorial association already reviewed.');
|
||||
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location->geo_location_id)->exists(), 422, 'Territory is not a public reference location.');
|
||||
if ($location->legal_source_id) {
|
||||
$source = DB::table('investment_legal_sources')->where('id', $location->legal_source_id)->first();
|
||||
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
|
||||
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Location source is not valid.');
|
||||
}
|
||||
DB::table('investment_zone_locations')->where('id', $id)->update(['validation_status' => 'validated', 'reviewer_id' => $actor,
|
||||
'version_no' => $version + 1, 'updated_at' => now()]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zone_locations',
|
||||
'entity_id' => $id, 'actor_user_id' => $actor, 'action' => 'validated',
|
||||
'after_data' => json_encode(['version_no' => $version + 1], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return DB::table('investment_zone_locations')->where('id', $id)->first();
|
||||
});
|
||||
}
|
||||
|
||||
private function snapshot(InvestmentZone $zone, int $actor, string $action): void
|
||||
{
|
||||
$json = $zone->toJson(JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Malware;
|
||||
|
||||
final class ArchiveLimitInspector
|
||||
{
|
||||
public function inspect(string $path): ?string
|
||||
{
|
||||
$stream = @fopen($path, 'rb');
|
||||
if (! is_resource($stream)) {
|
||||
return 'archive_inspection_failed';
|
||||
}
|
||||
$signature = fread($stream, 4);
|
||||
fclose($stream);
|
||||
|
||||
if (! is_string($signature) || ! in_array($signature, ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
return 'archive_inspector_unavailable';
|
||||
}
|
||||
|
||||
$archive = new \ZipArchive;
|
||||
if ($archive->open($path, \ZipArchive::RDONLY) !== true) {
|
||||
return 'archive_inspection_failed';
|
||||
}
|
||||
|
||||
try {
|
||||
$maxEntries = max(1, (int) config('services.malware.max_archive_entries', 1000));
|
||||
$maxExpandedBytes = max(1, (int) config('services.malware.max_decompressed_bytes', 104857600));
|
||||
if ($archive->numFiles > $maxEntries) {
|
||||
return 'archive_entry_limit_exceeded';
|
||||
}
|
||||
|
||||
$expandedBytes = 0;
|
||||
for ($index = 0; $index < $archive->numFiles; $index++) {
|
||||
$entry = $archive->statIndex($index);
|
||||
if (! is_array($entry) || ! isset($entry['size']) || ! is_int($entry['size']) || $entry['size'] < 0) {
|
||||
return 'archive_inspection_failed';
|
||||
}
|
||||
|
||||
if ($entry['size'] > $maxExpandedBytes - $expandedBytes) {
|
||||
return 'archive_decompression_limit';
|
||||
}
|
||||
$expandedBytes += $entry['size'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
$archive->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Malware;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class ClamDScanner implements MalwareScanner
|
||||
{
|
||||
private string $engineVersion = '';
|
||||
|
||||
private ?DateTimeImmutable $signaturesAt = null;
|
||||
|
||||
public function health(): array
|
||||
{
|
||||
try {
|
||||
$pong = $this->command('PING');
|
||||
if ($pong !== 'PONG') {
|
||||
return $this->unavailable('clamd_protocol_error');
|
||||
}
|
||||
|
||||
$version = $this->command('VERSION');
|
||||
if (! preg_match('/^ClamAV\s+([^\/\s]+)\/(\d+)\/(.+)$/', $version, $matches)) {
|
||||
return $this->unavailable('signature_version_unavailable');
|
||||
}
|
||||
|
||||
$dateText = preg_replace('/\s+/', ' ', trim($matches[3]));
|
||||
$date = DateTimeImmutable::createFromFormat('D M j H:i:s Y', (string) $dateText, new DateTimeZone('UTC'));
|
||||
if (! $date || $date->getTimestamp() > time() + 3600) {
|
||||
return $this->unavailable('signature_date_invalid');
|
||||
}
|
||||
|
||||
$ageHours = max(0, (time() - $date->getTimestamp()) / 3600);
|
||||
$this->engineVersion = $matches[1];
|
||||
$this->signaturesAt = $date;
|
||||
if ($ageHours > (float) config('services.malware.max_signature_age_hours', 48)) {
|
||||
return [
|
||||
'available' => false, 'engine' => 'ClamAV', 'engine_version' => $this->engineVersion,
|
||||
'signatures_at' => $date, 'signature_age_hours' => $ageHours, 'reason_code' => 'signatures_stale',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true, 'engine' => 'ClamAV', 'engine_version' => $this->engineVersion,
|
||||
'signatures_at' => $date, 'signature_age_hours' => $ageHours, 'reason_code' => null,
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
$reason = str_contains(strtolower($exception->getMessage()), 'timed out')
|
||||
? 'scanner_timeout'
|
||||
: 'scanner_unavailable';
|
||||
|
||||
return $this->unavailable($reason);
|
||||
}
|
||||
}
|
||||
|
||||
public function scan(string $path, int $fileSizeBytes, string $storageRoot): array
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$health = $this->health();
|
||||
if (! $health['available']) {
|
||||
return $this->result('inconclusive', $health['reason_code'], $startedAt, null);
|
||||
}
|
||||
|
||||
$root = realpath($storageRoot);
|
||||
$resolved = realpath($path);
|
||||
if (! $root || ! $resolved || ! is_file($resolved)
|
||||
|| ! str_starts_with(strtolower($resolved), strtolower($root.DIRECTORY_SEPARATOR))) {
|
||||
return $this->result('inconclusive', 'private_file_unavailable', $startedAt, null);
|
||||
}
|
||||
|
||||
$actualSize = filesize($resolved);
|
||||
if (! is_int($actualSize) || $actualSize !== $fileSizeBytes) {
|
||||
return $this->result('inconclusive', 'file_size_changed', $startedAt, null);
|
||||
}
|
||||
if ($actualSize > (int) config('services.malware.max_file_bytes', 20 * 1024 * 1024)
|
||||
|| $actualSize > (int) config('services.malware.max_stream_bytes', 25 * 1024 * 1024)) {
|
||||
return $this->result('inconclusive', 'file_scan_limit_exceeded', $startedAt, null);
|
||||
}
|
||||
|
||||
$archiveFailure = app(ArchiveLimitInspector::class)->inspect($resolved);
|
||||
if ($archiveFailure !== null) {
|
||||
return $this->result('inconclusive', $archiveFailure, $startedAt, null);
|
||||
}
|
||||
|
||||
$socket = null;
|
||||
$file = fopen($resolved, 'rb');
|
||||
if (! is_resource($file)) {
|
||||
return $this->result('inconclusive', 'private_file_unavailable', $startedAt, null);
|
||||
}
|
||||
|
||||
try {
|
||||
$socket = $this->connect();
|
||||
$this->writeAll($socket, "zINSTREAM\0");
|
||||
$sent = 0;
|
||||
while (! feof($file)) {
|
||||
$chunk = fread($file, 65536);
|
||||
if ($chunk === false) {
|
||||
throw new RuntimeException('The private file could not be streamed to clamd.');
|
||||
}
|
||||
if ($chunk === '') {
|
||||
break;
|
||||
}
|
||||
$sent += strlen($chunk);
|
||||
if ($sent > $actualSize || $sent > (int) config('services.malware.max_stream_bytes', 25 * 1024 * 1024)) {
|
||||
return $this->result('inconclusive', 'stream_limit_exceeded', $startedAt, null);
|
||||
}
|
||||
$this->writeAll($socket, pack('N', strlen($chunk)).$chunk);
|
||||
}
|
||||
if ($sent !== $actualSize) {
|
||||
return $this->result('inconclusive', 'file_size_changed', $startedAt, null);
|
||||
}
|
||||
|
||||
$this->writeAll($socket, pack('N', 0));
|
||||
$reply = $this->readReply($socket);
|
||||
if (str_ends_with($reply, ': OK')) {
|
||||
return $this->result('clean', null, $startedAt, null);
|
||||
}
|
||||
|
||||
if (preg_match('/:\s(.+) FOUND$/', $reply, $matches)) {
|
||||
$signature = $matches[1];
|
||||
if (preg_match('/encrypted|password|limits?\.exceeded|oversized/i', $signature)) {
|
||||
$reason = preg_match('/encrypted|password/i', $signature)
|
||||
? 'encrypted_or_password_protected'
|
||||
: 'scanner_decompression_limit';
|
||||
|
||||
return $this->result('inconclusive', $reason, $startedAt, $signature);
|
||||
}
|
||||
|
||||
return $this->result('infected', 'threat_detected', $startedAt, $signature);
|
||||
}
|
||||
|
||||
if (stripos($reply, 'size limit exceeded') !== false || stripos($reply, 'limit exceeded') !== false) {
|
||||
return $this->result('inconclusive', 'scanner_decompression_limit', $startedAt, $reply);
|
||||
}
|
||||
|
||||
throw new RuntimeException('ClamD returned an unrecognized scan response.');
|
||||
} catch (Throwable $exception) {
|
||||
$reason = str_contains(strtolower($exception->getMessage()), 'timed out')
|
||||
? 'scanner_timeout'
|
||||
: 'scanner_unavailable';
|
||||
|
||||
return $this->result('inconclusive', $reason, $startedAt, null);
|
||||
} finally {
|
||||
fclose($file);
|
||||
if (is_resource($socket)) {
|
||||
fclose($socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function command(string $command): string
|
||||
{
|
||||
$socket = $this->connect();
|
||||
try {
|
||||
$this->writeAll($socket, 'z'.$command."\0");
|
||||
|
||||
return $this->readReply($socket);
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return resource */
|
||||
private function connect()
|
||||
{
|
||||
if (config('services.malware.scanner') !== 'clamd') {
|
||||
throw new RuntimeException('No production scanner is configured.');
|
||||
}
|
||||
|
||||
$host = (string) config('services.malware.host', '127.0.0.1');
|
||||
$port = (int) config('services.malware.port', 3310);
|
||||
if ($host === '' || in_array($host, ['0.0.0.0', '::'], true) || $port < 1 || $port > 65535) {
|
||||
throw new RuntimeException('The ClamD endpoint is invalid.');
|
||||
}
|
||||
|
||||
$timeout = max(1, (int) config('services.malware.timeout_seconds', 120));
|
||||
$socket = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, min(10, $timeout));
|
||||
if (! is_resource($socket)) {
|
||||
throw new RuntimeException('ClamD is unavailable.');
|
||||
}
|
||||
stream_set_timeout($socket, $timeout);
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
/** @param resource $socket */
|
||||
private function writeAll($socket, string $data): void
|
||||
{
|
||||
$offset = 0;
|
||||
$length = strlen($data);
|
||||
while ($offset < $length) {
|
||||
$written = fwrite($socket, substr($data, $offset, 1024 * 1024));
|
||||
if ($written === false || $written === 0) {
|
||||
throw new RuntimeException('ClamD connection stopped accepting scan data.');
|
||||
}
|
||||
$offset += $written;
|
||||
$meta = stream_get_meta_data($socket);
|
||||
if ($meta['timed_out'] ?? false) {
|
||||
throw new RuntimeException('ClamD scan timed out while sending data.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param resource $socket */
|
||||
private function readReply($socket): string
|
||||
{
|
||||
$reply = stream_get_line($socket, 65536, "\0");
|
||||
$meta = stream_get_meta_data($socket);
|
||||
if ($reply === false || ($meta['timed_out'] ?? false)) {
|
||||
throw new RuntimeException(($meta['timed_out'] ?? false) ? 'ClamD scan timed out.' : 'ClamD returned no scan result.');
|
||||
}
|
||||
|
||||
return trim($reply);
|
||||
}
|
||||
|
||||
private function unavailable(string $reason): array
|
||||
{
|
||||
return ['available' => false, 'engine' => 'ClamAV', 'engine_version' => null, 'signatures_at' => null,
|
||||
'signature_age_hours' => null, 'reason_code' => $reason];
|
||||
}
|
||||
|
||||
private function result(string $result, ?string $reason, int $startedAt, ?string $detail): array
|
||||
{
|
||||
return [
|
||||
'result' => $result, 'reason_code' => $reason, 'engine' => 'ClamAV',
|
||||
'engine_version' => $this->engineVersion, 'signatures_at' => $this->signaturesAt,
|
||||
'duration_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000), 'detail' => $detail,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Malware;
|
||||
|
||||
interface MalwareScanner
|
||||
{
|
||||
/** @return array{result:string,reason_code:?string,engine:string,engine_version:string,signatures_at:\DateTimeImmutable,duration_ms:int,detail:?string} */
|
||||
public function scan(string $path, int $fileSizeBytes, string $storageRoot): array;
|
||||
|
||||
/** @return array{available:bool,engine:string,engine_version:?string,signatures_at:?\DateTimeImmutable,signature_age_hours:?float,reason_code:?string} */
|
||||
public function health(): array;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ final class SimulationExecution
|
||||
$scope = $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous');
|
||||
$scopeHash = hash('sha256', $scope);
|
||||
$keyHash = hash('sha256', strtolower($key));
|
||||
$payload = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'organization_id' => $organizationId];
|
||||
$payload = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning', 'organization_id' => $organizationId];
|
||||
ksort($payload);
|
||||
$payloadHash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
|
||||
@@ -44,7 +44,7 @@ final class SimulationExecution
|
||||
$simulation = Simulation::create([
|
||||
'id' => $id, 'questionnaire_version_id' => '61000100-0000-4000-8000-000000000001', 'owner_user_id' => $actorId, 'organization_id' => $organizationId,
|
||||
'anonymous_token_hash' => $tokenHash, 'status' => 'queued', 'version_no' => 1,
|
||||
'input_snapshot' => $payload, 'calculation_snapshot' => ['engine' => 'portal-tax-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
|
||||
'input_snapshot' => $payload, 'calculation_snapshot' => ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
|
||||
]);
|
||||
$scenarioId = (string) Str::uuid();
|
||||
DB::table('simulation_scenarios')->insert([
|
||||
@@ -88,7 +88,7 @@ final class SimulationExecution
|
||||
$this->requireMatch($request, $simulation);
|
||||
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $id)->where('id', $scenarioId)->lockForUpdate()->first();
|
||||
abort_if($scenario === null, 404);
|
||||
$parameters = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction']];
|
||||
$parameters = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning'];
|
||||
DB::table('simulation_scenarios')->where('id', $scenarioId)->update([
|
||||
'label' => $data['label'], 'parameters' => json_encode($parameters, JSON_THROW_ON_ERROR), 'version_no' => $scenario->version_no + 1, 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
final class SimulationFormDraftPrivacyPolicy
|
||||
{
|
||||
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data
|
||||
* @return array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>}
|
||||
*/
|
||||
public static function apply(array $data, bool $authenticated): array
|
||||
{
|
||||
if (! $authenticated) {
|
||||
$data['personal_snapshot'] = [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,43 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\User;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class SimulationFormDrafts
|
||||
{
|
||||
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data */
|
||||
/**
|
||||
* @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data
|
||||
* @return array{draft: SimulationFormDraft, replayed: bool}
|
||||
*/
|
||||
public function create(array $data, Request $request, string $key): array
|
||||
{
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
$data = SimulationFormDraftPrivacyPolicy::apply($data, $actorId !== null);
|
||||
$scope = $actorId === null ? 'anonymous:'.$tokenHash : 'user:'.$actorId;
|
||||
$keyHash = hash_hmac('sha256', strtolower($key), (string) config('app.key'));
|
||||
$canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
$payloadHash = hash('sha256', $canonicalPayload);
|
||||
|
||||
return DB::transaction(function () use ($actorId, $tokenHash, $scope, $keyHash, $payloadHash, $canonicalPayload, $data): array {
|
||||
return DB::transaction(function () use ($actorId, $tokenHash, $scope, $keyHash, $payloadHash, $data): array {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
DB::select('select pg_advisory_xact_lock(261010, hashtext(?))', [$scope]);
|
||||
|
||||
$query = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate();
|
||||
$query = $actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId);
|
||||
$draft = $query->first();
|
||||
if ($actorId === null) {
|
||||
SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
||||
->whereNull('deleted_at')->where('expires_at', '<=', now())->lockForUpdate()
|
||||
->update(['deleted_at' => now()]);
|
||||
}
|
||||
|
||||
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->first();
|
||||
if ($draft) {
|
||||
if (hash_equals($draft->create_key_hash, $keyHash)) {
|
||||
abort_unless(hash_equals($draft->create_payload_hash, $payloadHash), 409, 'Idempotency-Key já foi utilizada com outros dados.');
|
||||
@@ -40,6 +54,7 @@ final class SimulationFormDrafts
|
||||
'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $payloadHash,
|
||||
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
|
||||
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
|
||||
'expires_at' => $actorId === null ? now()->addDays(30) : null,
|
||||
]);
|
||||
|
||||
return ['draft' => $draft, 'replayed' => false];
|
||||
@@ -50,31 +65,260 @@ final class SimulationFormDrafts
|
||||
public function update(string $id, array $data, Request $request): SimulationFormDraft
|
||||
{
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
$data = SimulationFormDraftPrivacyPolicy::apply($data, $actorId !== null);
|
||||
|
||||
return DB::transaction(function () use ($id, $data, $request, $actorId, $tokenHash): SimulationFormDraft {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
$draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
|
||||
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
||||
$this->requireMatch($request, $draft);
|
||||
$previousEconomic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
if (($data['economic_snapshot']['reuse_simulation'] ?? null) === 'no') {
|
||||
unset($data['economic_snapshot']['simulation_sources']);
|
||||
} elseif (is_array($previousEconomic['simulation_sources'] ?? null)) {
|
||||
$data['economic_snapshot']['simulation_sources'] = $previousEconomic['simulation_sources'];
|
||||
}
|
||||
$canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
$draft->update([
|
||||
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
|
||||
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
|
||||
'snapshot_sha256' => hash('sha256', $canonicalPayload), 'version_no' => $draft->version_no + 1,
|
||||
'expires_at' => $actorId === null ? now()->addDays(30) : null,
|
||||
]);
|
||||
|
||||
return $draft->refresh();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function renew(string $id, Request $request): SimulationFormDraft
|
||||
{
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
|
||||
return DB::transaction(function () use ($id, $request, $actorId, $tokenHash): SimulationFormDraft {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
||||
$this->requireMatch($request, $draft);
|
||||
$draft->update(['expires_at' => $actorId === null ? now()->addDays(30) : null]);
|
||||
|
||||
return $draft->refresh();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $sourceSnapshot
|
||||
* @return array{draft: SimulationFormDraft, replayed: bool}
|
||||
*/
|
||||
public function attachSimulation(string $id, array $sourceSnapshot, Request $request): array
|
||||
{
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
$sourceId = (string) ($sourceSnapshot['simulation_id'] ?? '');
|
||||
|
||||
return DB::transaction(function () use ($id, $sourceSnapshot, $sourceId, $request, $actorId, $tokenHash): array {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
abort_unless(isset($economic['purpose']) && $economic['purpose'] !== 'simulate_only', 422, 'Escolha uma finalidade de investimento antes de converter a simulação.');
|
||||
$sources = is_array($economic['simulation_sources'] ?? null) ? $economic['simulation_sources'] : [];
|
||||
foreach ($sources as $source) {
|
||||
if (is_array($source) && ($source['simulation_id'] ?? null) === $sourceId) {
|
||||
return ['draft' => $draft, 'replayed' => true];
|
||||
}
|
||||
}
|
||||
|
||||
$this->requireMatch($request, $draft);
|
||||
$sources[] = $sourceSnapshot;
|
||||
$economic['simulation_sources'] = $sources;
|
||||
$economic['reuse_simulation'] = 'yes';
|
||||
$personal = SimulationFormDraft::decryptSnapshot($draft->personal_snapshot);
|
||||
$canonicalPayload = json_encode($this->canonical([
|
||||
'personal_snapshot' => $personal,
|
||||
'economic_snapshot' => $economic,
|
||||
]), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
$draft->update([
|
||||
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($economic),
|
||||
'snapshot_sha256' => hash('sha256', $canonicalPayload),
|
||||
'version_no' => $draft->version_no + 1,
|
||||
'expires_at' => $actorId === null ? now()->addDays(30) : null,
|
||||
]);
|
||||
|
||||
$plainToken = $request->header('X-Simulation-Token');
|
||||
$auditTokenHash = is_string($plainToken) && $plainToken !== ''
|
||||
? hash_hmac('sha256', $plainToken, (string) config('app.key'))
|
||||
: null;
|
||||
$this->setContext($actorId, $auditTokenHash ?? $tokenHash);
|
||||
DB::table('simulation_audit_events')->insert([
|
||||
'simulation_id' => $sourceId,
|
||||
'actor_user_id' => $actorId,
|
||||
'action' => 'simulation.attached_to_draft',
|
||||
'correlation_id' => $draft->id,
|
||||
'data' => json_encode(['draft_id' => $draft->id, 'report_sha256' => $sourceSnapshot['report_sha256']], JSON_THROW_ON_ERROR),
|
||||
'occurred_at' => now(),
|
||||
]);
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
|
||||
return ['draft' => $draft->refresh(), 'replayed' => false];
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function current(Request $request): ?SimulationFormDraft
|
||||
{
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
|
||||
return DB::transaction(function () use ($actorId, $tokenHash): ?SimulationFormDraft {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
$query = SimulationFormDraft::query()->whereNull('deleted_at');
|
||||
return $this->ownedQuery($actorId, $tokenHash)->orderByDesc('updated_at')->first();
|
||||
});
|
||||
}
|
||||
|
||||
return ($actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId))->first();
|
||||
public function forgetCurrent(Request $request): void
|
||||
{
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
abort_unless($actorId === null && is_string($tokenHash), 422);
|
||||
|
||||
DB::transaction(function () use ($tokenHash): void {
|
||||
$this->setContext(null, $tokenHash);
|
||||
SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
||||
->whereNull('deleted_at')->lockForUpdate()->update(['deleted_at' => now()]);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @return list<array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}> */
|
||||
public function ownedDrafts(User $actor): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor): array {
|
||||
$this->setContext($actor->id, null);
|
||||
|
||||
return array_values($this->ownedQuery($actor->id, null)->orderByDesc('updated_at')->get()
|
||||
->map(fn (SimulationFormDraft $draft): array => $this->summary($draft))->all());
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
|
||||
public function claimPreview(Request $request): ?array
|
||||
{
|
||||
abort_unless($request->user() instanceof User, 401);
|
||||
$tokenHash = $this->anonymousTokenHash($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $tokenHash): ?array {
|
||||
$this->setContext($request->user()->id, $tokenHash);
|
||||
$draft = SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
||||
->whereNull('deleted_at')->where('expires_at', '>', now())->first();
|
||||
|
||||
return $draft ? $this->summary($draft) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{draft:SimulationFormDraft,choice:string,replayed?:bool} */
|
||||
public function claim(Request $request, string $choice, ?string $existingDraftId = null): array
|
||||
{
|
||||
$actor = $request->user();
|
||||
abort_unless($actor instanceof User && $actor->hasVerifiedEmail(), 403);
|
||||
$tokenHash = $this->anonymousTokenHash($request);
|
||||
|
||||
return DB::transaction(function () use ($actor, $tokenHash, $choice, $existingDraftId): array {
|
||||
$this->setContext($actor->id, $tokenHash);
|
||||
$anonymous = SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
||||
->whereNull('deleted_at')->where('expires_at', '>', now())->lockForUpdate()->first();
|
||||
if (! $anonymous && $choice === 'save_as_new') {
|
||||
$claimed = $this->ownedQuery($actor->id, null)->where('claimed_token_hash', $tokenHash)->lockForUpdate()->first();
|
||||
if ($claimed) {
|
||||
return ['draft' => $claimed, 'choice' => $choice, 'replayed' => true];
|
||||
}
|
||||
}
|
||||
abort_unless($anonymous !== null, 404, 'O rascunho anónimo expirou ou já não está disponível.');
|
||||
|
||||
if ($choice === 'continue_existing') {
|
||||
abort_unless(is_string($existingDraftId) && Str::isUuid($existingDraftId), 422);
|
||||
$existing = $this->ownedQuery($actor->id, null)->whereKey($existingDraftId)->firstOrFail();
|
||||
|
||||
return ['draft' => $existing, 'choice' => $choice];
|
||||
}
|
||||
|
||||
abort_unless($choice === 'save_as_new', 422);
|
||||
$anonymous->update(['owner_user_id' => $actor->id, 'anonymous_token_hash' => null, 'claimed_token_hash' => $tokenHash, 'expires_at' => null,
|
||||
'version_no' => $anonymous->version_no + 1]);
|
||||
|
||||
return ['draft' => $anonymous->refresh(), 'choice' => $choice, 'replayed' => false];
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @return list<array{id:string,title:string,modality:string,status:string}> */
|
||||
public function linkableBusinesses(User $actor): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor): array {
|
||||
$this->setContext($actor->id, null);
|
||||
|
||||
$businesses = app(BusinessRepository::class)->query()->where('owner_user_id', $actor->id)->whereNull('deleted_at')
|
||||
->orderByDesc('updated_at')->get()->filter(fn (ExistingBusiness $business): bool => Gate::forUser($actor)->allows('owns', $business))
|
||||
->map(fn (ExistingBusiness $business): array => ['id' => $business->id,
|
||||
'title' => (string) ($business->public_title ?: ($business->payload['title'] ?? 'Negócio sem título')),
|
||||
'modality' => $business->modality, 'status' => $business->status])->values()->all();
|
||||
|
||||
return array_values($businesses);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{id:string,business_id:string,title:string,linked_at:string}|array{replayed:true} */
|
||||
public function linkBusiness(User $actor, string $draftId, string $businessId): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $draftId, $businessId): array {
|
||||
$this->setContext($actor->id, null);
|
||||
$draft = $this->ownedQuery($actor->id, null)->whereKey($draftId)->lockForUpdate()->firstOrFail();
|
||||
$business = app(BusinessRepository::class)->find($businessId, true);
|
||||
Gate::forUser($actor)->authorize('owns', $business);
|
||||
$active = DB::table('simulation_draft_business_links')->where('draft_id', $draft->id)->whereNull('unlinked_at')->lockForUpdate()->first();
|
||||
if ($active) {
|
||||
abort_unless($active->business_id === $business->id, 409, 'Desassocie o negócio actual antes de escolher outro.');
|
||||
|
||||
return ['replayed' => true];
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('simulation_draft_business_links')->insert(['id' => $id, 'draft_id' => $draft->id,
|
||||
'business_id' => $business->id, 'owner_user_id' => $actor->id, 'linked_by' => $actor->id,
|
||||
'linked_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return ['id' => $id, 'business_id' => $business->id,
|
||||
'title' => (string) ($business->public_title ?: ($business->payload['title'] ?? 'Negócio sem título')),
|
||||
'linked_at' => now()->toISOString()];
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @return array{unlinked:bool} */
|
||||
public function unlinkBusiness(User $actor, string $draftId): array
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $draftId): array {
|
||||
$this->setContext($actor->id, null);
|
||||
$draft = $this->ownedQuery($actor->id, null)->whereKey($draftId)->lockForUpdate()->firstOrFail();
|
||||
$link = DB::table('simulation_draft_business_links')->where('draft_id', $draft->id)->whereNull('unlinked_at')->lockForUpdate()->first();
|
||||
if (! $link) {
|
||||
return ['unlinked' => false];
|
||||
}
|
||||
DB::table('simulation_draft_business_links')->where('id', $link->id)->update(['unlinked_at' => now(),
|
||||
'unlinked_by' => $actor->id, 'updated_at' => now()]);
|
||||
|
||||
return ['unlinked' => true];
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function activeBusinessLink(User $actor, string $draftId): ?array
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $draftId): ?array {
|
||||
$this->setContext($actor->id, null);
|
||||
$this->ownedQuery($actor->id, null)->whereKey($draftId)->firstOrFail();
|
||||
$row = DB::table('simulation_draft_business_links')->where('draft_id', $draftId)
|
||||
->where('owner_user_id', $actor->id)->whereNull('unlinked_at')->first();
|
||||
if (! $row) {
|
||||
return null;
|
||||
}
|
||||
$business = app(BusinessRepository::class)->find($row->business_id);
|
||||
if (! Gate::forUser($actor)->allows('owns', $business)) {
|
||||
return ['id' => $row->id, 'business_id' => $row->business_id,
|
||||
'title' => 'Negócio indisponível para esta conta',
|
||||
'linked_at' => Carbon::parse($row->linked_at)->toISOString()];
|
||||
}
|
||||
|
||||
return ['id' => $row->id, 'business_id' => $row->business_id,
|
||||
'title' => (string) ($business->public_title ?: ($business->payload['title'] ?? 'Negócio sem título')),
|
||||
'linked_at' => Carbon::parse($row->linked_at)->toISOString()];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,7 +329,7 @@ final class SimulationFormDrafts
|
||||
return DB::transaction(function () use ($id, $actorId, $tokenHash): SimulationFormDraft {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
|
||||
return SimulationFormDraft::query()->whereNull('deleted_at')->findOrFail($id);
|
||||
return $this->ownedQuery($actorId, $tokenHash)->findOrFail($id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,22 +338,28 @@ final class SimulationFormDrafts
|
||||
[$actorId, $tokenHash] = $this->context($request);
|
||||
DB::transaction(function () use ($id, $request, $actorId, $tokenHash): void {
|
||||
$this->setContext($actorId, $tokenHash);
|
||||
$draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
|
||||
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
||||
$this->requireMatch($request, $draft);
|
||||
$draft->update(['deleted_at' => now()]);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function present(SimulationFormDraft $draft): array
|
||||
public function present(SimulationFormDraft $draft, bool $includePersonal): array
|
||||
{
|
||||
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
$simulationSources = is_array($economic['simulation_sources'] ?? null) ? $economic['simulation_sources'] : [];
|
||||
unset($economic['simulation_sources']);
|
||||
|
||||
return [
|
||||
'id' => $draft->id,
|
||||
'version_no' => $draft->version_no,
|
||||
'snapshot_sha256' => $draft->snapshot_sha256,
|
||||
'personal_snapshot' => SimulationFormDraft::decryptSnapshot($draft->personal_snapshot),
|
||||
'economic_snapshot' => SimulationFormDraft::decryptSnapshot($draft->economic_snapshot),
|
||||
'personal_snapshot' => $includePersonal ? SimulationFormDraft::decryptSnapshot($draft->personal_snapshot) : [],
|
||||
'economic_snapshot' => $economic,
|
||||
'simulation_sources' => $simulationSources,
|
||||
'updated_at' => $draft->updated_at,
|
||||
'expires_at' => $draft->expires_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -129,6 +379,14 @@ final class SimulationFormDrafts
|
||||
return [$actorId, $actorId === null ? hash_hmac('sha256', $token, (string) config('app.key')) : null];
|
||||
}
|
||||
|
||||
private function anonymousTokenHash(Request $request): string
|
||||
{
|
||||
$token = $request->header('X-Simulation-Token');
|
||||
abort_unless(is_string($token) && strlen($token) >= 32 && strlen($token) <= 256, 422, 'A recuperação do rascunho expirou. Reinicie pelo mesmo navegador ou dispositivo.');
|
||||
|
||||
return hash_hmac('sha256', $token, (string) config('app.key'));
|
||||
}
|
||||
|
||||
private function setContext(?int $actorId, ?string $tokenHash): void
|
||||
{
|
||||
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_token_hash', ?, true)", [(string) ($actorId ?? ''), (string) ($tokenHash ?? '')]);
|
||||
@@ -145,6 +403,26 @@ final class SimulationFormDrafts
|
||||
}
|
||||
}
|
||||
|
||||
/** @return Builder<SimulationFormDraft> */
|
||||
private function ownedQuery(?int $actorId, ?string $tokenHash): Builder
|
||||
{
|
||||
$query = SimulationFormDraft::query()->whereNull('deleted_at');
|
||||
|
||||
return $actorId === null
|
||||
? $query->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)->where('expires_at', '>', now())
|
||||
: $query->where('owner_user_id', $actorId)->whereNull('anonymous_token_hash');
|
||||
}
|
||||
|
||||
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int} */
|
||||
private function summary(SimulationFormDraft $draft): array
|
||||
{
|
||||
$snapshot = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
||||
|
||||
return ['id' => $draft->id, 'title' => (string) ($snapshot['project_title'] ?? 'Projecto sem título'),
|
||||
'updated_at' => $draft->updated_at?->toISOString(), 'expires_at' => $draft->expires_at?->toISOString(),
|
||||
'version_no' => (int) $draft->version_no];
|
||||
}
|
||||
|
||||
private function canonical(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
final class SimulationTaxGovernanceAccess
|
||||
{
|
||||
private const ROLES = ['editors', 'reviewers', 'approvers', 'publishers'];
|
||||
|
||||
public static function requireRole(User $user, string $role): void
|
||||
{
|
||||
abort_unless(in_array($role, self::ROLES, true), 500, 'Função de governação desconhecida.');
|
||||
abort_unless(in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true),
|
||||
403, 'O utilizador não está designado para esta função de governação fiscal.');
|
||||
}
|
||||
|
||||
public static function requireAnyRole(User $user): void
|
||||
{
|
||||
foreach (self::ROLES as $role) {
|
||||
if (in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
abort(403, 'O utilizador não está designado para consultar a governação fiscal.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Selects a published, date-effective parameter from an explicitly scoped candidate set. */
|
||||
final class SimulationTaxParameterResolver
|
||||
{
|
||||
/** @param array<array-key,mixed> $parameters */
|
||||
public function validateParameters(array $parameters): void
|
||||
{
|
||||
if ($parameters === [] || count($parameters) > 100) {
|
||||
throw new InvalidArgumentException('Provide between 1 and 100 versioned parameters.');
|
||||
}
|
||||
foreach ($parameters as $code => $parameter) {
|
||||
if (! is_string($code) || ! preg_match('/^[A-Z][A-Z0-9._-]{1,63}$/', $code) || ! is_array($parameter)
|
||||
|| array_diff(array_keys($parameter), ['value', 'unit', 'conditions']) !== []
|
||||
|| array_diff(['value', 'unit', 'conditions'], array_keys($parameter)) !== []) {
|
||||
throw new InvalidArgumentException('Each parameter requires only value, unit, and conditions.');
|
||||
}
|
||||
if ((! is_string($parameter['value']) && ! is_int($parameter['value'])) || (string) $parameter['value'] === ''
|
||||
|| ! is_string($parameter['unit']) || ! preg_match('/^[a-z][a-z0-9._-]{0,31}$/', $parameter['unit'])
|
||||
|| ! is_array($parameter['conditions'])) {
|
||||
throw new InvalidArgumentException('Invalid parameter value, unit, or conditions.');
|
||||
}
|
||||
$isNumericValue = preg_match('/^-?\d+(?:\.\d+)?$/', (string) $parameter['value']) === 1;
|
||||
if (in_array($parameter['unit'], ['percent', 'mzn', 'amount', 'days', 'count', 'mzn_per_unit', 'per_unit'], true) && ! $isNumericValue) {
|
||||
throw new InvalidArgumentException('Numeric parameter units require a plain decimal value.');
|
||||
}
|
||||
if ($isNumericValue) {
|
||||
$value = BigRational::of((string) $parameter['value']);
|
||||
if ($value->isNegative() || ($parameter['unit'] === 'percent' && $value->compareTo(100) > 0)) {
|
||||
throw new InvalidArgumentException('Numeric parameters cannot be negative; percentages must be between 0 and 100.');
|
||||
}
|
||||
}
|
||||
foreach ($parameter['conditions'] as $condition => $expected) {
|
||||
if (! is_string($condition) || ! is_string($expected)) {
|
||||
throw new InvalidArgumentException('Conditions must be a flat map of string assumptions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only rows made visible by the published-only RLS policy. The method
|
||||
* never executes a tax formula and requires each parameter to declare context.
|
||||
*
|
||||
* @param array<string,string> $context
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context): array
|
||||
{
|
||||
$start = $this->date($periodStart);
|
||||
$end = $this->date($periodEnd);
|
||||
if ($end < $start || $start->diff($end)->days > 365) {
|
||||
throw new InvalidArgumentException('Invalid effective period.');
|
||||
}
|
||||
|
||||
$rows = DB::table('simulation_tax_rule_versions as rule')
|
||||
->join('investment_legal_sources as source', 'source.id', '=', 'rule.legal_source_id')
|
||||
->where('rule.component', $component)
|
||||
->where('rule.status', 'published')
|
||||
->whereDate('rule.valid_from', '<=', $periodEnd)
|
||||
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
|
||||
->where('source.validation_status', 'validated')
|
||||
->whereColumn('source.version_no', 'rule.source_version_no')
|
||||
->whereDate('source.valid_from', '<=', $periodStart)
|
||||
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
|
||||
->select([
|
||||
'rule.id', 'rule.rule_code', 'rule.rule_version', 'rule.component', 'rule.scope_key',
|
||||
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
|
||||
'rule.source_version_no', 'rule.source_sha256',
|
||||
'source.title as source_title', 'source.diploma as source_diploma', 'source.article as source_article',
|
||||
'source.url as source_url', 'source.validation_status as source_validation_status',
|
||||
'source.version_no as source_current_version', 'source.valid_from as source_valid_from',
|
||||
'source.valid_until as source_valid_until',
|
||||
])
|
||||
->orderBy('rule.rule_code')->orderBy('rule.rule_version')->limit(500)->get();
|
||||
|
||||
$versions = array_values($rows->map(function (object $row): array {
|
||||
$parameters = is_array($row->parameters)
|
||||
? $row->parameters
|
||||
: json_decode((string) $row->parameters, true, flags: JSON_THROW_ON_ERROR);
|
||||
$definition = is_array($row->rule_definition)
|
||||
? $row->rule_definition
|
||||
: json_decode((string) $row->rule_definition, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'rule_code' => $row->rule_code,
|
||||
'rule_version' => (int) $row->rule_version,
|
||||
'component' => $row->component,
|
||||
'scope_key' => $row->scope_key,
|
||||
'status' => 'published',
|
||||
'valid_from' => (string) $row->valid_from,
|
||||
'valid_until' => $row->valid_until === null ? null : (string) $row->valid_until,
|
||||
'rule_definition' => $definition,
|
||||
'parameters' => $parameters,
|
||||
'source_version_no' => (int) $row->source_version_no,
|
||||
'source_sha256' => $row->source_sha256,
|
||||
'source' => [
|
||||
'title' => $row->source_title,
|
||||
'diploma' => $row->source_diploma,
|
||||
'article' => $row->source_article,
|
||||
'url' => $row->source_url,
|
||||
'validation_status' => $row->source_validation_status,
|
||||
'version_no' => (int) $row->source_current_version,
|
||||
'valid_from' => (string) $row->source_valid_from,
|
||||
'valid_until' => $row->source_valid_until === null ? null : (string) $row->source_valid_until,
|
||||
],
|
||||
];
|
||||
})->all());
|
||||
|
||||
return $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $versions
|
||||
* @param array<string,string> $context
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function resolveAvailableParameters(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
|
||||
{
|
||||
$start = $this->date($periodStart);
|
||||
$end = $this->date($periodEnd);
|
||||
$groups = [];
|
||||
|
||||
foreach ($versions as $version) {
|
||||
if (($version['component'] ?? null) !== $component
|
||||
|| ($version['status'] ?? null) !== 'published'
|
||||
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|
||||
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|
||||
|| ! $this->overlaps($version, $start, $end)
|
||||
|| ! $this->covers($version['source'], $start) || ! $this->covers($version['source'], $end)
|
||||
|| ($version['rule_definition']['language'] ?? null) !== 'investmoz.tax.v1'
|
||||
|| ! is_array($version['parameters'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$coversWholePeriod = $this->covers($version, $start) && $this->covers($version, $end);
|
||||
foreach ($version['parameters'] as $code => $parameter) {
|
||||
if (! is_string($code) || ! is_array($parameter) || ! is_array($parameter['conditions'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$groups[$code] ??= ['matches' => [], 'partial' => false, 'saw_unscoped' => false, 'saw_context_mismatch' => false];
|
||||
$conditions = $parameter['conditions'];
|
||||
if (! $coversWholePeriod) {
|
||||
if ($conditions !== [] && $this->conditionsMatch($conditions, $context) && ($version['scope_key'] ?? '') !== '') {
|
||||
$groups[$code]['partial'] = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($conditions === []) {
|
||||
$groups[$code]['saw_unscoped'] = true;
|
||||
} elseif (! $this->conditionsMatch($conditions, $context)) {
|
||||
$groups[$code]['saw_context_mismatch'] = true;
|
||||
} elseif (($version['scope_key'] ?? '') === '') {
|
||||
$groups[$code]['saw_unscoped'] = true;
|
||||
} else {
|
||||
$groups[$code]['matches'][] = [$version, $parameter];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($groups as $code => $group) {
|
||||
if ($group['partial']) {
|
||||
$state = count($group['matches']) > 0 ? 'indeterminate_overlap' : 'indeterminate_period_change';
|
||||
$resolved[] = ['parameter_code' => $code, 'status' => $state, 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
if (count($group['matches']) !== 1) {
|
||||
$state = count($group['matches']) > 1
|
||||
? 'indeterminate_overlap'
|
||||
: ($group['saw_unscoped'] ? 'indeterminate_scope' : ($group['saw_context_mismatch'] ? 'indeterminate_context' : 'indeterminate_missing'));
|
||||
$resolved[] = ['parameter_code' => $code, 'status' => $state, 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
[$version, $parameter] = $group['matches'][0];
|
||||
if (! isset($parameter['value'], $parameter['unit']) || ! is_string($parameter['unit'])) {
|
||||
$resolved[] = ['parameter_code' => $code, 'status' => 'indeterminate_invalid', 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$resolved[] = [
|
||||
'parameter_code' => $code,
|
||||
'status' => 'resolved',
|
||||
'value' => (string) $parameter['value'],
|
||||
'unit' => $parameter['unit'],
|
||||
'conditions' => $parameter['conditions'],
|
||||
'scope_key' => $version['scope_key'],
|
||||
'rule_code' => $version['rule_code'],
|
||||
'rule_id' => $version['id'],
|
||||
'rule_version' => (int) $version['rule_version'],
|
||||
'period' => ['start' => $periodStart, 'end' => $periodEnd],
|
||||
'validity' => ['start' => $version['valid_from'], 'end' => $version['valid_until'] ?? null],
|
||||
'source' => [
|
||||
'title' => $version['source']['title'],
|
||||
'diploma' => $version['source']['diploma'],
|
||||
'article' => $version['source']['article'],
|
||||
'url' => $version['source']['url'],
|
||||
'declared_sha256' => $version['source_sha256'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
usort($resolved, static fn (array $left, array $right): int => $left['parameter_code'] <=> $right['parameter_code']);
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<array<string,mixed>> $publishedVersions Caller must supply only records visible under the applicable access policy.
|
||||
* @param array<string,scalar|null> $conditions
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function resolve(iterable $publishedVersions, string $component, string $scopeKey, string $asOf, string $parameterCode, array $conditions = []): array
|
||||
{
|
||||
$date = $this->date($asOf);
|
||||
$matches = [];
|
||||
foreach ($publishedVersions as $version) {
|
||||
if (($version['status'] ?? null) !== 'published'
|
||||
|| ($version['component'] ?? null) !== $component
|
||||
|| ($version['scope_key'] ?? null) !== $scopeKey
|
||||
|| ! $this->covers($version, $date)
|
||||
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|
||||
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|
||||
|| ! $this->covers($version['source'], $date)
|
||||
|| ! preg_match('/^[a-f0-9]{64}$/D', (string) ($version['source_sha256'] ?? ''))) {
|
||||
continue;
|
||||
}
|
||||
$parameter = $version['parameters'][$parameterCode] ?? null;
|
||||
if (! is_array($parameter) || ! isset($parameter['value'], $parameter['unit']) || ! is_string($parameter['unit'])
|
||||
|| ! is_array($parameter['conditions'] ?? null) || ! $this->conditionsMatch($parameter['conditions'], $conditions)) {
|
||||
continue;
|
||||
}
|
||||
$matches[] = [$version, $parameter];
|
||||
}
|
||||
|
||||
if (count($matches) !== 1) {
|
||||
return ['status' => count($matches) > 1 ? 'indeterminate_overlap' : 'indeterminate_missing', 'value' => null, 'parameter_code' => $parameterCode];
|
||||
}
|
||||
[$version, $parameter] = $matches[0];
|
||||
|
||||
return [
|
||||
'status' => 'resolved',
|
||||
'value' => (string) $parameter['value'],
|
||||
'unit' => $parameter['unit'],
|
||||
'parameter_code' => $parameterCode,
|
||||
'conditions' => $parameter['conditions'],
|
||||
'rule_code' => $version['rule_code'],
|
||||
'rule_version' => (int) $version['rule_version'],
|
||||
'rule_id' => $version['id'],
|
||||
'scope_key' => $scopeKey,
|
||||
'valid_from' => $version['valid_from'],
|
||||
'valid_until' => $version['valid_until'] ?? null,
|
||||
'source' => [
|
||||
'title' => $version['source']['title'],
|
||||
'diploma' => $version['source']['diploma'],
|
||||
'article' => $version['source']['article'],
|
||||
'url' => $version['source']['url'],
|
||||
'declared_sha256' => $version['source_sha256'],
|
||||
'validation_status' => $version['source']['validation_status'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $version */
|
||||
private function covers(array $version, DateTimeImmutable $date): bool
|
||||
{
|
||||
$fromValue = $version['valid_from'] ?? null;
|
||||
$untilValue = $version['valid_until'] ?? null;
|
||||
if (! is_string($fromValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $fromValue)
|
||||
|| ($untilValue !== null && (! is_string($untilValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $untilValue)))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromValue);
|
||||
$until = $untilValue === null ? null : DateTimeImmutable::createFromFormat('!Y-m-d', $untilValue);
|
||||
if ($from === false || $from->format('Y-m-d') !== $fromValue
|
||||
|| ($untilValue !== null && ($until === false || $until->format('Y-m-d') !== $untilValue))) {
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $date >= $from && ($until === null || $date <= $until);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $version */
|
||||
private function overlaps(array $version, DateTimeImmutable $start, DateTimeImmutable $end): bool
|
||||
{
|
||||
$fromValue = $version['valid_from'] ?? null;
|
||||
$untilValue = $version['valid_until'] ?? null;
|
||||
if (! is_string($fromValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $fromValue)) {
|
||||
return false;
|
||||
}
|
||||
$from = $this->date($fromValue);
|
||||
$until = $untilValue === null ? null : (is_string($untilValue) ? $this->date($untilValue) : null);
|
||||
|
||||
return $from <= $end && ($until === null || $until >= $start);
|
||||
}
|
||||
|
||||
private function date(string $date): DateTimeImmutable
|
||||
{
|
||||
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
|
||||
throw new InvalidArgumentException('Effective date must use YYYY-MM-DD.');
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $required
|
||||
* @param array<string,scalar|null> $actual
|
||||
*/
|
||||
private function conditionsMatch(?array $required, array $actual): bool
|
||||
{
|
||||
if ($required === null) {
|
||||
return false;
|
||||
}
|
||||
foreach ($required as $key => $value) {
|
||||
if (! array_key_exists($key, $actual) || $actual[$key] !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\RoundingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** A deliberately small, data-only expression language for reviewed tax-rule drafts. */
|
||||
final class SimulationTaxRuleExpression
|
||||
{
|
||||
private const LANGUAGE = 'investmoz.tax.v1';
|
||||
|
||||
private const ROUNDING = [
|
||||
'UNNECESSARY' => RoundingMode::Unnecessary,
|
||||
'UP' => RoundingMode::Up,
|
||||
'DOWN' => RoundingMode::Down,
|
||||
'CEILING' => RoundingMode::Ceiling,
|
||||
'FLOOR' => RoundingMode::Floor,
|
||||
'HALF_UP' => RoundingMode::HalfUp,
|
||||
'HALF_DOWN' => RoundingMode::HalfDown,
|
||||
'HALF_CEILING' => RoundingMode::HalfCeiling,
|
||||
'HALF_FLOOR' => RoundingMode::HalfFloor,
|
||||
'HALF_EVEN' => RoundingMode::HalfEven,
|
||||
];
|
||||
|
||||
/** @param array<string,mixed> $definition */
|
||||
public function validate(array $definition): void
|
||||
{
|
||||
$this->topologicalOrder($definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a validated expression. Missing values are reported as indeterminate;
|
||||
* callers must supply versioned parameters and provenance explicitly.
|
||||
*
|
||||
* @param array<string,mixed> $definition
|
||||
* @param array<string,string|int> $inputs
|
||||
* @param array<string,string|int> $parameters
|
||||
* @return array{status:string,result:?string,missing:list<string>,steps:list<array<string,mixed>>}
|
||||
*/
|
||||
public function evaluate(array $definition, array $inputs, array $parameters): array
|
||||
{
|
||||
$order = $this->topologicalOrder($definition);
|
||||
$nodes = $definition['nodes'];
|
||||
$values = [];
|
||||
$missing = [];
|
||||
$steps = [];
|
||||
|
||||
foreach ($order as $id) {
|
||||
$node = $nodes[$id];
|
||||
$op = $node['op'];
|
||||
if ($op === 'input' || $op === 'parameter') {
|
||||
$key = $node['key'];
|
||||
$source = $op === 'input' ? $inputs : $parameters;
|
||||
if (! array_key_exists($key, $source)) {
|
||||
$missing[] = $op.':'.$key;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'key' => $key, 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->number($source[$key]);
|
||||
} elseif ($op === 'literal') {
|
||||
$value = $this->number($node['value']);
|
||||
} else {
|
||||
$args = array_values(array_map(static fn (string $ref): ?BigRational => $values[$ref] ?? null, $node['args'] ?? []));
|
||||
if (in_array(null, $args, true)) {
|
||||
$values[$id] = null;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'args' => $node['args'] ?? [], 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->operate($op, $args, $node);
|
||||
}
|
||||
$values[$id] = $value;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'value' => (string) $value];
|
||||
}
|
||||
|
||||
$result = $values[$definition['result']] ?? null;
|
||||
|
||||
return [
|
||||
'status' => $missing === [] && $result !== null ? 'determinate' : 'indeterminate',
|
||||
'result' => $result === null ? null : (string) $result,
|
||||
'missing' => array_values(array_unique($missing)),
|
||||
'steps' => $steps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $definition
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topologicalOrder(array $definition): array
|
||||
{
|
||||
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
|
||||
throw new InvalidArgumentException('Unsupported expression definition.');
|
||||
}
|
||||
$nodes = $definition['nodes'];
|
||||
if ($nodes === [] || count($nodes) > 100) {
|
||||
throw new InvalidArgumentException('Expression must contain between 1 and 100 nodes.');
|
||||
}
|
||||
|
||||
foreach ($nodes as $id => $node) {
|
||||
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
|
||||
throw new InvalidArgumentException('Invalid expression node.');
|
||||
}
|
||||
$op = $node['op'];
|
||||
$allowedFields = match ($op) {
|
||||
'input', 'parameter' => ['op', 'key'],
|
||||
'literal' => ['op', 'value'],
|
||||
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
|
||||
'round' => ['op', 'args', 'scale', 'mode'],
|
||||
default => throw new InvalidArgumentException('Expression operation is not allowed.'),
|
||||
};
|
||||
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
|
||||
throw new InvalidArgumentException('Expression node has missing or unsupported fields.');
|
||||
}
|
||||
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
|
||||
throw new InvalidArgumentException('Invalid input or parameter key.');
|
||||
}
|
||||
if ($op === 'literal') {
|
||||
$this->number($node['value']);
|
||||
}
|
||||
if (isset($node['args'])) {
|
||||
if (! is_array($node['args'])) {
|
||||
throw new InvalidArgumentException('Invalid expression arguments.');
|
||||
}
|
||||
$count = count($node['args']);
|
||||
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
|
||||
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
|
||||
throw new InvalidArgumentException('Invalid expression arguments.');
|
||||
}
|
||||
}
|
||||
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
|
||||
throw new InvalidArgumentException('Invalid explicit rounding configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
if (! array_key_exists($definition['result'], $nodes)) {
|
||||
throw new InvalidArgumentException('Expression result references an unknown node.');
|
||||
}
|
||||
$state = [];
|
||||
$order = [];
|
||||
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
|
||||
if (($state[$id] ?? 0) === 1) {
|
||||
throw new InvalidArgumentException('Expression dependency graph contains a cycle.');
|
||||
}
|
||||
if (($state[$id] ?? 0) === 2) {
|
||||
return;
|
||||
}
|
||||
$state[$id] = 1;
|
||||
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
|
||||
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
|
||||
throw new InvalidArgumentException('Expression references an unknown node.');
|
||||
}
|
||||
$visit($dependency);
|
||||
}
|
||||
$state[$id] = 2;
|
||||
$order[] = $id;
|
||||
};
|
||||
foreach (array_keys($nodes) as $id) {
|
||||
$visit($id);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<BigRational> $args
|
||||
* @param array<string,mixed> $node
|
||||
*/
|
||||
private function operate(string $op, array $args, array $node): BigRational
|
||||
{
|
||||
if ($op === 'round') {
|
||||
return BigRational::of((string) $args[0]->toScale($node['scale'], self::ROUNDING[$node['mode']]));
|
||||
}
|
||||
if ($op === 'add') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->plus($value), $args[0]);
|
||||
}
|
||||
if ($op === 'subtract') {
|
||||
return $args[0]->minus($args[1]);
|
||||
}
|
||||
if ($op === 'multiply') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->multipliedBy($value), $args[0]);
|
||||
}
|
||||
if ($op === 'divide') {
|
||||
return $args[0]->dividedBy($args[1]);
|
||||
}
|
||||
if ($op === 'min' || $op === 'max') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
|
||||
}
|
||||
throw new InvalidArgumentException('Expression operation is not allowed.');
|
||||
}
|
||||
|
||||
private function number(mixed $value): BigRational
|
||||
{
|
||||
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d+(?:\.\d+)?$/', (string) $value)) {
|
||||
throw new InvalidArgumentException('Numbers must be plain decimal strings or integers.');
|
||||
}
|
||||
|
||||
return BigRational::of((string) $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use App\Models\SimulationTaxRuleVersion;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class SimulationTaxRuleGovernance
|
||||
{
|
||||
/** @param array<string,mixed> $data */
|
||||
public function create(User $actor, array $data): SimulationTaxRuleVersion
|
||||
{
|
||||
SimulationTaxGovernanceAccess::requireRole($actor, 'editors');
|
||||
|
||||
return DB::transaction(function () use ($actor, $data): SimulationTaxRuleVersion {
|
||||
$this->setContext($actor);
|
||||
$source = $this->sourceSnapshot($data);
|
||||
$rule = SimulationTaxRuleVersion::query()->create([
|
||||
...$data,
|
||||
'id' => (string) Str::uuid(),
|
||||
'status' => 'draft',
|
||||
'author_id' => $actor->id,
|
||||
'source_version_no' => $source->version_no,
|
||||
'version_no' => 1,
|
||||
]);
|
||||
$this->event($rule, $actor, 'created');
|
||||
|
||||
return $rule->refresh();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function update(User $actor, string $id, int $version, array $data): SimulationTaxRuleVersion
|
||||
{
|
||||
SimulationTaxGovernanceAccess::requireRole($actor, 'editors');
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $version, $data): SimulationTaxRuleVersion {
|
||||
$this->setContext($actor);
|
||||
$rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id);
|
||||
$this->matchVersion($rule, $version);
|
||||
abort_unless((int) $rule->author_id === $actor->id && $rule->status === 'draft', 403,
|
||||
'Só o autor pode alterar um rascunho ainda não submetido. Crie uma nova versão da regra após revisão.');
|
||||
$source = $this->sourceSnapshot($data);
|
||||
$rule->fill([...$data, 'source_version_no' => $source->version_no, 'version_no' => $version + 1])->save();
|
||||
$this->event($rule, $actor, 'updated');
|
||||
|
||||
return $rule->refresh();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function transition(User $actor, string $id, int $version, string $action, string $evidence): SimulationTaxRuleVersion
|
||||
{
|
||||
$role = match ($action) {
|
||||
'submit' => 'editors',
|
||||
'review', 'reject' => 'reviewers',
|
||||
'approve' => 'approvers',
|
||||
'publish', 'suspend', 'archive' => 'publishers',
|
||||
default => throw new HttpException(422, 'Transição de governação inválida.'),
|
||||
};
|
||||
SimulationTaxGovernanceAccess::requireRole($actor, $role);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $version, $action, $evidence): SimulationTaxRuleVersion {
|
||||
$this->setContext($actor);
|
||||
$rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id);
|
||||
$this->matchVersion($rule, $version);
|
||||
$next = ['version_no' => $version + 1];
|
||||
|
||||
switch ($action) {
|
||||
case 'submit':
|
||||
abort_unless((int) $rule->author_id === $actor->id && $rule->status === 'draft', 403,
|
||||
'Só o autor pode submeter o seu rascunho.');
|
||||
$this->sourceStillValid($rule);
|
||||
$next += ['status' => 'submitted', 'submitted_at' => now()];
|
||||
break;
|
||||
case 'review':
|
||||
case 'reject':
|
||||
abort_unless((int) $rule->author_id !== $actor->id, 403, 'O autor não pode rever a própria regra.');
|
||||
abort_unless($rule->status === 'submitted', 422, 'A regra tem de estar submetida para revisão.');
|
||||
$this->sourceStillValid($rule);
|
||||
$next += ['status' => $action === 'review' ? 'reviewed' : 'rejected', 'reviewer_id' => $actor->id,
|
||||
'review_evidence' => $evidence, 'reviewed_at' => now()];
|
||||
break;
|
||||
case 'approve':
|
||||
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id, 403,
|
||||
'Autor e revisor não podem aprovar a mesma regra.');
|
||||
abort_unless($rule->status === 'reviewed', 422, 'A regra tem de estar revista antes da aprovação.');
|
||||
$this->sourceStillValid($rule);
|
||||
$next += ['status' => 'approved', 'approver_id' => $actor->id,
|
||||
'approval_evidence' => $evidence, 'approved_at' => now()];
|
||||
break;
|
||||
case 'publish':
|
||||
abort_unless($rule->status === 'approved', 422, 'A regra tem de estar aprovada antes da publicação.');
|
||||
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id
|
||||
&& (int) $rule->approver_id !== $actor->id, 403,
|
||||
'Autor, revisor e aprovador não podem publicar a mesma regra.');
|
||||
$this->sourceStillValid($rule);
|
||||
$this->assertNoPublishedOverlap($rule);
|
||||
$next += ['status' => 'published', 'publisher_id' => $actor->id,
|
||||
'publication_evidence' => $evidence, 'published_at' => now()];
|
||||
break;
|
||||
case 'suspend':
|
||||
abort_unless($rule->status === 'published', 422, 'Só uma regra publicada pode ser suspensa.');
|
||||
$next += ['status' => 'suspended', 'closure_evidence' => $evidence, 'suspended_at' => now()];
|
||||
break;
|
||||
case 'archive':
|
||||
abort_unless(in_array($rule->status, ['published', 'suspended'], true), 422,
|
||||
'Só uma regra publicada ou suspensa pode ser arquivada.');
|
||||
$next += ['status' => 'archived', 'closure_evidence' => $evidence, 'archived_at' => now()];
|
||||
break;
|
||||
}
|
||||
|
||||
$rule->fill($next)->save();
|
||||
$this->event($rule, $actor, $action);
|
||||
|
||||
return $rule->refresh();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
public function listing(User $actor, array $filters): mixed
|
||||
{
|
||||
SimulationTaxGovernanceAccess::requireAnyRole($actor);
|
||||
|
||||
return DB::transaction(function () use ($actor, $filters) {
|
||||
$this->setContext($actor);
|
||||
$query = SimulationTaxRuleVersion::query()->orderByDesc('created_at');
|
||||
foreach (['rule_code', 'component', 'scope_key', 'status'] as $key) {
|
||||
if (isset($filters[$key])) {
|
||||
$query->where($key, $filters[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $query->paginate($filters['per_page'] ?? 20)->withQueryString();
|
||||
});
|
||||
}
|
||||
|
||||
public function show(User $actor, string $id): SimulationTaxRuleVersion
|
||||
{
|
||||
SimulationTaxGovernanceAccess::requireAnyRole($actor);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id): SimulationTaxRuleVersion {
|
||||
$this->setContext($actor);
|
||||
|
||||
return SimulationTaxRuleVersion::query()->findOrFail($id);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function sourceSnapshot(array $data): InvestmentLegalSource
|
||||
{
|
||||
$source = InvestmentLegalSource::query()->whereKey($data['legal_source_id'])->firstOrFail();
|
||||
abort_unless(preg_match('/^[a-f0-9]{64}$/D', (string) $data['source_sha256']) === 1, 422,
|
||||
'Indique o SHA-256 do exemplar oficial efectivamente revisto.');
|
||||
abort_unless($source->validation_status === 'validated', 422, 'A fonte jurídica ainda não foi validada.');
|
||||
abort_unless($source->valid_from <= $data['valid_from']
|
||||
&& ($source->valid_until === null || ($data['valid_until'] !== null && $source->valid_until >= $data['valid_until']))
|
||||
&& ($data['valid_until'] !== null || $source->valid_until === null), 422,
|
||||
'A vigência da fonte não cobre todo o período declarado para a regra.');
|
||||
|
||||
return $source;
|
||||
}
|
||||
|
||||
private function sourceStillValid(SimulationTaxRuleVersion $rule): void
|
||||
{
|
||||
$source = InvestmentLegalSource::query()->lockForUpdate()->find($rule->legal_source_id);
|
||||
abort_unless($source !== null && $source->validation_status === 'validated'
|
||||
&& (int) $source->version_no === (int) $rule->source_version_no
|
||||
&& $source->valid_from <= $rule->valid_from->format('Y-m-d')
|
||||
&& ($source->valid_until === null || ($rule->valid_until !== null && $source->valid_until >= $rule->valid_until->format('Y-m-d')))
|
||||
&& ($rule->valid_until !== null || $source->valid_until === null), 422,
|
||||
'A fonte aprovada foi revogada, alterada ou não cobre toda a vigência da regra.');
|
||||
}
|
||||
|
||||
private function assertNoPublishedOverlap(SimulationTaxRuleVersion $rule): void
|
||||
{
|
||||
DB::select('select pg_advisory_xact_lock(hashtext(?), hashtext(?))', [$rule->rule_code, $rule->scope_key]);
|
||||
$overlap = SimulationTaxRuleVersion::query()->where('id', '<>', $rule->id)->where('status', 'published')
|
||||
->where('component', $rule->component)->where('rule_code', $rule->rule_code)->where('scope_key', $rule->scope_key)
|
||||
->whereDate('valid_from', '<=', $rule->valid_until?->format('Y-m-d') ?? '9999-12-31')
|
||||
->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $rule->valid_from->format('Y-m-d')))
|
||||
->exists();
|
||||
abort_unless(! $overlap, 409, 'Já existe regra publicada com vigência sobreposta para o mesmo código, componente e scope_key.');
|
||||
}
|
||||
|
||||
private function matchVersion(SimulationTaxRuleVersion $rule, int $version): void
|
||||
{
|
||||
abort_unless((int) $rule->version_no === $version, 409, 'Versão alterada. Recarregue antes de continuar.');
|
||||
}
|
||||
|
||||
private function setContext(User $actor): void
|
||||
{
|
||||
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $actor->id]);
|
||||
}
|
||||
|
||||
private function event(SimulationTaxRuleVersion $rule, User $actor, string $action): void
|
||||
{
|
||||
$snapshot = $this->canonical($rule->attributesToArray());
|
||||
$json = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
$canonicalJson = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
|
||||
DB::table('simulation_tax_rule_events')->insert([
|
||||
'id' => (string) Str::uuid(), 'rule_version_id' => $rule->id, 'version_no' => $rule->version_no,
|
||||
'actor_user_id' => $actor->id, 'action' => $action, 'snapshot' => $json,
|
||||
'sha256' => hash('sha256', $canonicalJson), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function canonical(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->canonical($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -3,14 +3,16 @@
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\PreserveFormFeedback;
|
||||
use App\Http\Middleware\EnsureSimulationRecoveryCookie;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\ConnectionException;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -20,7 +22,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', EnsureSimulationRecoveryCookie::NAME]);
|
||||
|
||||
$middleware->web(append: [
|
||||
HandleAppearance::class,
|
||||
@@ -42,6 +44,34 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
);
|
||||
}
|
||||
|
||||
if (($exception instanceof QueryException || $exception instanceof ConnectionException || $exception instanceof \PDOException)
|
||||
&& ! $request->is('api/v1/simulation-drafts*')) {
|
||||
$reference = (string) ($response->headers->get('X-Correlation-ID')
|
||||
?? $request->attributes->get('correlation_id')
|
||||
?? Str::uuid());
|
||||
$response->setStatusCode(503);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
$response->setContent(json_encode([
|
||||
'message' => 'Serviço temporariamente indisponível. Tente novamente mais tarde.',
|
||||
'reference' => $reference,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
$response->headers->set('Content-Type', 'application/json; charset=utf-8');
|
||||
} else {
|
||||
$response->setContent(view('errors.database-unavailable', ['reference' => $reference])->render());
|
||||
$response->headers->set('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->is('api/v1/simulation-drafts*') && $response->isServerError()) {
|
||||
$response->setContent(json_encode([
|
||||
'message' => 'Não foi possível guardar ou recuperar o rascunho. Os dados existentes não foram apagados. Tente novamente mais tarde.',
|
||||
'reference' => $response->headers->get('X-Correlation-ID'),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
$response->headers->set('Content-Type', 'application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
return $response;
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Technical evidence identifiers only. They are not laws, obligations, or tax rules.
|
||||
'catalogue' => [
|
||||
'fiscal.ice.lei_7_2025' => ['label' => 'ICE — Lei n.º 7/2025', 'tax' => 'ICE'],
|
||||
'fiscal.customs.lei_8_2025' => ['label' => 'Pauta aduaneira — Lei n.º 8/2025', 'tax' => 'Aduaneiro'],
|
||||
'fiscal.ispc.lei_9_2025' => ['label' => 'ISPC — Lei n.º 9/2025', 'tax' => 'ISPC'],
|
||||
'fiscal.iva.lei_10_2025' => ['label' => 'IVA — Lei n.º 10/2025', 'tax' => 'IVA'],
|
||||
'fiscal.irps.lei_11_2025' => ['label' => 'IRPS — Lei n.º 11/2025', 'tax' => 'IRPS'],
|
||||
'fiscal.irpc.lei_12_2025' => ['label' => 'IRPC — Lei n.º 12/2025', 'tax' => 'IRPC'],
|
||||
'fiscal.iva.lei_22_2022' => ['label' => 'IVA — Lei n.º 22/2022, art. 17(1)', 'tax' => 'IVA'],
|
||||
'fiscal.iva.lei_13_2016_rectification_2017' => ['label' => 'CIVA — rectificação da Lei n.º 13/2016 (BR 89/2017)', 'tax' => 'IVA'],
|
||||
],
|
||||
];
|
||||
@@ -5,5 +5,6 @@ return [
|
||||
// No legal or GIS mandate is granted by public registration or this configuration.
|
||||
'legal_reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_LEGAL_REVIEWERS', '')))))),
|
||||
'gis_reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_GIS_REVIEWERS', '')))))),
|
||||
'territory_reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_TERRITORY_REVIEWERS', '')))))),
|
||||
'publishers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_PUBLISHERS', '')))))),
|
||||
];
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ return [
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 210),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
return [
|
||||
|
||||
'malware' => [
|
||||
'scanner' => env('MALWARE_SCANNER', 'clamd'),
|
||||
'host' => env('CLAMD_HOST', '127.0.0.1'),
|
||||
'port' => (int) env('CLAMD_PORT', 3310),
|
||||
'timeout_seconds' => (int) env('CLAMD_TIMEOUT_SECONDS', 120),
|
||||
'max_signature_age_hours' => (int) env('CLAMD_MAX_SIGNATURE_AGE_HOURS', 48),
|
||||
'max_file_bytes' => (int) env('CLAMD_MAX_FILE_BYTES', 20971520),
|
||||
'max_stream_bytes' => (int) env('CLAMD_MAX_STREAM_BYTES', 26214400),
|
||||
'max_decompressed_bytes' => (int) env('CLAMD_MAX_DECOMPRESSED_BYTES', 104857600),
|
||||
'max_archive_entries' => (int) env('CLAMD_MAX_ARCHIVE_ENTRIES', 1000),
|
||||
'max_attempts' => (int) env('MALWARE_SCAN_MAX_ATTEMPTS', 3),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// These lists are deliberately empty by default. A portal manager and MFA are
|
||||
// also required; governance roles never activate rules in the calculation engine.
|
||||
'editors' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_EDITORS', '')))))),
|
||||
'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_REVIEWERS', '')))))),
|
||||
'approvers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_APPROVERS', '')))))),
|
||||
'publishers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_PUBLISHERS', '')))))),
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"source_document_attached": true,
|
||||
"source_document_reconciled": true,
|
||||
"source_document_title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_document_note": "Documento do utilizador reconciliado com fontes institucionais e primárias acessíveis. Alegações promocionais não são tratadas como actividade autorizada, operação ou disponibilidade.",
|
||||
"consulted_at": "2026-10-04",
|
||||
"records": [
|
||||
{"code":"MOZ-TOPUITO-PARK","name":"Parque Industrial de Topuito","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"in_development","availability_status":"reported_unverified","territory_note":"The source names Topuito in Larde; the master dataset has no district row for Larde. This candidate is linked only at Nampula province until the territorial mapping is reviewed.","territories":[{"name":"NAMPULA","type":"province","scope_type":"administrative"}],"source":{"title":"Construction begins on SME Village at Topuito Industrial Park","url":"https://mozparks.co.mz/construction-begins-on-sme-village-at-topuito-industrial-park/","source_type":"promoter","supports":"Project and development activity, not operational availability."},"areas":[{"type":"total","value":215,"unit":"ha","status":"disputed","source_reference":"MozParks brochure 2023, source scope to review"},{"type":"total","value":200,"unit":"ha","status":"disputed","source_reference":"MozParks brochure 2025, source scope to review"},{"type":"developed","value":30,"unit":"ha","status":"reported","source_reference":"MozParks brochure 2025; not a land availability statement"}]},
|
||||
{"code":"MOZ-TOPUITO-ZFI","name":"Zona Franca Industrial de Topuito","category":"zfi","physical_category":"industrial_free_zone","legal_regime":"zfi","development_status":"planned","availability_status":"not_informed","territory_note":"The decree names Topuito in Larde; the master dataset has no district row for Larde. This candidate is linked only at Nampula province until the territorial mapping is reviewed.","territories":[{"name":"NAMPULA","type":"province","scope_type":"administrative"}],"source":{"title":"Decreto n.º 57/2023 — criação da ZFI de Topuito","url":"https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-s%C3%A9rie-p%C3%A1g-2349","source_type":"government","supports":"Legal creation and reported legal area; primary review remains required."},"areas":[{"type":"legal","value":65,"unit":"ha","status":"reported","source_reference":"Decreto n.º 57/2023, artigo 1.º — confirm against retained official text"}]},
|
||||
{"code":"MOZ-MUNHAVA-PARK","name":"Parque Industrial e Logístico de Munhava","category":"industrial_park","physical_category":"industrial_logistics_park","legal_regime":"pending","development_status":"in_development","availability_status":"not_informed","territories":[{"name":"CIDADE DA BEIRA","type":"district","scope_type":"administrative"}],"source":{"title":"Parque Industrial e Logístico — SDUBeira","url":"https://sdubeira.co.mz/project/parque-industrial-logistico/","source_type":"promoter","supports":"Project existence, location and reported development status."},"areas":[{"type":"total","value":900,"unit":"ha","status":"disputed","source_reference":"SDUBeira project page, total area scope needs confirmation"},{"type":"total","value":990,"unit":"ha","status":"disputed","source_reference":"SDUBeira project headline, conflicts with page summary"},{"type":"pilot","value":50,"unit":"ha","status":"reported","source_reference":"SDUBeira project page; not available-land confirmation"}]},
|
||||
{"code":"MOZ-CDP-MONTEPUEZ","name":"Parque Industrial de Montepuez","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"MONTEPUEZ","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-CDP-BALAMA","name":"Parque Industrial de Balama","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"BALAMA","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-CDP-ANCUABE","name":"Parque Industrial de Ancuabe","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"ANCUABE","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-CDP-PALMA","name":"Parque Industrial de Palma","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"PALMA","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-REVUBUE-ZFI","name":"Zona Franca Industrial de Revúbuè","category":"zfi","physical_category":"industrial_free_zone","legal_regime":"zfi","development_status":"planned","availability_status":"not_informed","territories":[{"name":"CHIUTA","type":"district","scope_type":"legal_scope"},{"name":"MOATIZE","type":"district","scope_type":"legal_scope"}],"source":{"title":"Revúbuè Free Industrial Zone — APIEX","url":"https://apiex.gov.mz/revubue-free-industrial-zone/","source_type":"institutional","supports":"APIEX-reported identity, district scope and area; primary legal text pending."},"areas":[{"type":"legal","value":4456,"unit":"ha","status":"reported","source_reference":"APIEX page; confirm perimeter against Decreto 41/2016"}]},
|
||||
{"code":"MOZ-MINHEWENE-ZFI","name":"Zona Franca Industrial de Minhewene","category":"zfi","physical_category":"industrial_free_zone","legal_regime":"zfi","development_status":"planned","availability_status":"not_informed","territories":[{"name":"NAMPULA","type":"province","scope_type":"administrative"}],"source":{"title":"Zona Franca Industrial de Minhewene — APIEX","url":"https://apiex.gov.mz/zona-franca-industrial-de-minhewene/","source_type":"institutional","supports":"APIEX-reported identity, province and area; primary legal text and site precision pending."},"areas":[{"type":"legal","value":350,"unit":"ha","status":"reported","source_reference":"APIEX page; confirm perimeter against Decreto 51/2011"}]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
{
|
||||
"source_document": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"provided": true,
|
||||
"received_at": "2026-10-04",
|
||||
"classification": "documento fornecido pelo utilizador; referncias citadas sem URLs/ficheiros primrios anexos; claims no so por si s prova oficial",
|
||||
"note": "As afirmaes do documento foram comparadas com pginas institucionais/promotoras e referncias oficiais acessveis. Manter alegaes promocionais separadas de existncia jurdica, autorizao de actividade, operao e disponibilidade."
|
||||
},
|
||||
"consulted_at": "2026-10-04",
|
||||
"candidates": [
|
||||
{
|
||||
"name": "Beluluane",
|
||||
"outcome": "reconcile_existing",
|
||||
"prepared_codes": [
|
||||
"MOZ-BELULUANE-PARK",
|
||||
"MOZ-BELULUANE-ZFI"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/beluluane-industrial-park-free-zone/",
|
||||
"known": "MozParks apresenta 700 ha prontos para arrendamento; APIEX apresenta complexo de cerca de 780 ha: parque 600 ha, Mozal 180 ha e ZFI 55 ha. So mbitos que no podem ser somados nem equiparados a disponibilidade.",
|
||||
"pending": "Confirmar limites e sobreposio cartogrfica/documental, sobretudo relao entre 700 ha e parque de 600 ha. No publicar actividades como autorizadas nem declarar lotes disponveis.",
|
||||
"user_document_claims": {
|
||||
"status": "operacional e em expanso (alegao documental/promotora)",
|
||||
"area": "700 ha (alegao MozParks no portal actual); actividades/enfoque: manufactura, alumnio, cabos, agro-processamento, tecnologia, logstica e servios",
|
||||
"assessment": "MozParks apresenta 700 ha prontos para arrendamento; APIEX descreve um complexo de cerca de 780 ha composto por parque de 600 ha e Mozal 180 ha, e uma ZFI de 55 ha. Escopos no reconciliados; nenhum nmero prova disponibilidade actual."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Topuito",
|
||||
"outcome": "prepare_separate_park_and_zfi",
|
||||
"prepared_codes": [
|
||||
"MOZ-TOPUITO-PARK",
|
||||
"MOZ-TOPUITO-ZFI"
|
||||
],
|
||||
"source": "https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-s%C3%A9rie-p%C3%A1g-2349",
|
||||
"known": "MozParks apresenta Topuito como parque e oferta de 215 ha; notcia de Agosto de 2026 confirma incio da construo do SME Village. Decreto 57/2023 cria separadamente ZFI com rea legal de 65 ha.",
|
||||
"pending": "O parque, a obra do SME Village e a ZFI so escopos distintos. Verificar limites, operao e disponibilidade actual. Base territorial no contm distrito Larde; manter associao apenas provincial at reviso.",
|
||||
"user_document_claims": {
|
||||
"status": "operacional/em expanso (alegao documental/promotora)",
|
||||
"area": "215 ha (documento e perfil institucional MozParks)",
|
||||
"assessment": "MozParks identifica Topuito Industrial Park e notcia de Agosto de 2026 informa incio de construo do SME Village. A ZFI legal de 65 ha entidade/permetro distinto. No inferir que toda a rea esteja pronta ou disponvel."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Munhava",
|
||||
"outcome": "prepare_informational_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-MUNHAVA-PARK"
|
||||
],
|
||||
"source": "https://sdubeira.co.mz/project/parque-industrial-logistico/",
|
||||
"known": "SDUBeira descreve o projecto em fases, rea total de 900 ha no resumo e piloto de 50 ha; o destaque de 990 ha na mesma pgina cria divergncia.",
|
||||
"pending": "Documento de entrada acrescenta faixa aproximada de 44-60 ha para piloto; no corresponde a metragem confirmada. Obter permetro/plano primrio e no declarar terreno disponvel.",
|
||||
"user_document_claims": {
|
||||
"status": "desenvolvimento faseado",
|
||||
"area": "900 ha total e piloto de cerca de 44-60 ha (alegao documental); actividades/enfoque: logstica porturia, armazenagem, manufactura, agro-indstria e pescas",
|
||||
"assessment": "SDUBeira descreve 900 ha no resumo e 50 ha piloto; a mesma pgina contm destaque de 990 ha. Conservar divergncia editorial e no tratar piloto como terreno livre."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Montepuez",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-MONTEPUEZ"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Montepuez. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "fornecedores mineiros, transformao, servios e PME (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma quatro parques em desenvolvimento nos distritos; actividades so foco de promoo, no licena ou operao confirmada."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Balama",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-BALAMA"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Balama. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "minerao, servios industriais, fornecedores e PME (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma a iniciativa/distrito; actividades permanecem promocionais e no so autorizaes."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Ancuabe",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-ANCUABE"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Ancuabe. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "transformao, contedo local, servios industriais e PME (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma a iniciativa/distrito; actividades permanecem promocionais e no so autorizaes."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Palma",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-PALMA"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Palma. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "petrleo e gs, energia, logstica e cadeias locais (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma a iniciativa/distrito; actividades permanecem promocionais e no so autorizaes."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Selva/Manica Parks",
|
||||
"outcome": "keep_unresolved_program_and_site_distinct",
|
||||
"prepared_codes": [],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-manica-parks-a-new-industrial-platform-for-manica-province/",
|
||||
"known": "MozParks confirma Manica Parks como iniciativa provincial lanada por memorando em Setembro de 2026. O documento fornecido associa a iniciativa a Selva/Vanduzi, mas no confirma instalao individual.",
|
||||
"pending": "No criar parque individual nem relao territorial de Selva sem fonte especfica. Actividades listadas so enfoque declarado, no autorizao.",
|
||||
"user_document_claims": {
|
||||
"status": "Manica Parks lanado por memorando em Setembro de 2026; alegao de localizao em Selva/Vanduzi permanece por confirmar",
|
||||
"activities": "agro-processamento, metalomecnica, combustveis, hotelaria e restaurao (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma iniciativa provincial e memorando, no uma instalao individual em Selva nem incio de construo/operao."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mocuba ZFI",
|
||||
"outcome": "reconcile_existing",
|
||||
"prepared_codes": [
|
||||
"MOZ-MOCUBA-ZFI",
|
||||
"MOZ-MOCUBA-ZEE"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/zif/mocuba-free-industrial-zone/",
|
||||
"known": "APIEX confirma a ZFI de 50,58 ha e fase de mobilizao de recursos e operador. A lista de actividades do documento enfoque declarado, no autorizao.",
|
||||
"pending": "Confirmar diploma primrio, limites e actividades; manter ZFI e ZEE de Mocuba separadas.",
|
||||
"user_document_claims": {
|
||||
"status": "mobilizao de recursos e operador",
|
||||
"area": "50,58 ha segundo APIEX",
|
||||
"activities": "agro-florestal, pescas, txteis, calcrio, pedra e minerais (enfoque alegado no documento)",
|
||||
"assessment": "APIEX confirma 50,58 ha e mobilizao; confirmar actividades com fonte prpria antes de atribuir ao registo. Manter ZFI distinta da ZEE."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Revúbuè ZFI",
|
||||
"outcome": "prepare_one_record_two_districts",
|
||||
"prepared_codes": [
|
||||
"MOZ-REVUBUE-ZFI"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/revubue-free-industrial-zone/",
|
||||
"known": "APIEX reporta a ZFI abrangendo Chiuta e Moatize, 4.456 ha, sem operador identificado.",
|
||||
"pending": "Obter e validar texto primário do Decreto 41/2016 e as relações territoriais. Um único registo, duas localizações.",
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Mocambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
},
|
||||
"user_document_claims": {
|
||||
"status": "sem operador; um projecto aprovado segundo APIEX",
|
||||
"area": "4 456 ha segundo APIEX",
|
||||
"activities": "industria ligada a recursos e transformacao (enfoque alegado no documento)",
|
||||
"assessment": "APIEX informa um projecto aprovado e potenciais sectores; isto nao transforma o projecto em oportunidade publicada nem comprova actividades autorizadas."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Minhewene ZFI",
|
||||
"outcome": "prepare_province_precision_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-MINHEWENE-ZFI"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/zona-franca-industrial-de-minhewene/",
|
||||
"known": "Pgina APIEX actualmente acessvel reporta Decreto 51/2011, Nampula/Cidade de Nacala, 350 ha e mobilizao de recursos/operador. O documento fornecido diz que a ficha no estava acessvel; esta afirmao diverge da consulta actual.",
|
||||
"pending": "Validar Decreto 51/2011 e localizao cadastral exacta; pgina institucional no substitui fonte legal primria nem comprova operacionalizao actual.",
|
||||
"user_document_claims": {
|
||||
"status": "mobilizao de recursos e operador segundo pgina APIEX acessvel na verificao actual",
|
||||
"area": "350 ha segundo APIEX",
|
||||
"assessment": "A alegao do documento de que no havia ficha acessvel diverge da pgina institucional APIEX actualmente acessvel, que reporta Decreto 51/2011, Nampula/Cidade de Nacala, 350 ha e mobilizao de recursos/operador. Acessibilidade da pgina no substitui validao do diploma primrio ou do estado actual."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"legislative_review": {
|
||||
"decree_57_2023": "Texto oficial localizado; criação da ZFI de Topuito e 65 ha confirmados. Não implica que operador, actividade operacional ou terrenos disponíveis estejam confirmados.",
|
||||
"decree_12_2026": "A página oficial consultada apresenta o sumário de aprovação do regulamento ZEE/ZFI e revogação do Decreto 75/99. O texto integral e regras transitórias/entrada em vigor ainda não foram revistos; não parametrizar simulador fiscal a partir do sumário."
|
||||
},
|
||||
"official_checks": [
|
||||
{
|
||||
"topic": "Beluluane",
|
||||
"source": "https://mozparks.co.mz/",
|
||||
"finding": "MozParks apresenta 700 ha prontos para arrendamento; APIEX reporta complexo de cerca de 780 ha, parque 600 ha, Mozal 180 ha, ZFI 55 ha. Escopos divergentes."
|
||||
},
|
||||
{
|
||||
"topic": "Topuito",
|
||||
"source": "https://mozparks.co.mz/",
|
||||
"finding": "MozParks apresenta 215 ha; notcia de 19-08-2026 diz SME Village comeou construo. No declara a totalidade da rea operacional/disponvel."
|
||||
},
|
||||
{
|
||||
"topic": "Munhava",
|
||||
"source": "https://sdubeira.co.mz/project/parque-industrial-logistico/",
|
||||
"finding": "A pgina da SDUBeira contm total 900 ha no resumo e 990 ha em destaque; piloto 50 ha. A faixa 44-60 ha no foi corroborada."
|
||||
},
|
||||
{
|
||||
"topic": "Manica Parks",
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-manica-parks-a-new-industrial-platform-for-manica-province/",
|
||||
"finding": "Memorando e iniciativa provincial confirmados; Selva/Vanduzi no aparece como instalao individual confirmada."
|
||||
},
|
||||
{
|
||||
"topic": "Cabo Delgado Parks",
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"finding": "MozParks identifica parques em desenvolvimento nos quatro distritos. Sectores do documento permanecem promocionais."
|
||||
},
|
||||
{
|
||||
"topic": "Minhewene",
|
||||
"source": "https://apiex.gov.mz/zona-franca-industrial-de-minhewene/",
|
||||
"finding": "Pgina APIEX est acessvel nesta consulta e reporta Decreto 51/2011, Nacala, 350 ha e mobilizao de recursos/operador; a alegao documental de indisponibilidade da ficha no corresponde ao acesso actual."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"source_prompt": "docs/investmoz/source-staging/zone-integration-2026-10-04/Pasted text.txt",
|
||||
"consulted_at": "2026-10-04",
|
||||
"records": [
|
||||
{
|
||||
"code": "MOZ-BELULUANE-PARK",
|
||||
"name": "Parque Industrial de Beluluane",
|
||||
"name_en": "Beluluane Industrial Park",
|
||||
"category": "industrial_park",
|
||||
"physical_category": "industrial_park",
|
||||
"legal_regime": "general",
|
||||
"operational_status": "unknown",
|
||||
"area_value": null,
|
||||
"area_unit": null,
|
||||
"area_components": [
|
||||
{"type":"total","value":780,"unit":"ha","status":"disputed","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — APIEX reports the complex; scope and overlap need confirmation","source_date":null},
|
||||
{"type":"total","value":700,"unit":"ha","status":"disputed","source_reference":"Document cited in the integration prompt was not attached; claim retained as unresolved, not imported as verified data","source_date":null},
|
||||
{"type":"developed","value":600,"unit":"ha","status":"reported","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — APIEX describes the industrial park component","source_date":null},
|
||||
{"type":"developed","value":180,"unit":"ha","status":"reported","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — APIEX describes the Mozal site component; do not add as available land","source_date":null},
|
||||
{"type":"legal","value":55,"unit":"ha","status":"reported","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — separate ZFI component; do not merge with park","source_date":null}
|
||||
],
|
||||
"description": "A APIEX apresenta aproximadamente 780 ha para o conjunto referido como Parque Industrial de Beluluane–Zona Franca, incluindo componentes com perímetros distintos. O valor não representa área livre ou disponível. A fonte identifica historicamente Matola-Rio, Boane, Província de Maputo. Situação operacional e área disponível não confirmadas por esta referência.",
|
||||
"description_en": "APIEX reports approximately 780 ha for the Beluluane Industrial Park–Free Zone complex, including distinct components. This is not an available-land figure. The source historically identifies Matola-Rio, Boane, Maputo Province. Operational status and available area are not confirmed by this reference.",
|
||||
"aliases": ["PIB-ZF", "Parque Industrial de Beluluane–Zona Franca"],
|
||||
"activities": [],
|
||||
"historical_territory": {"name": "Matola-Rio", "district": "Boane", "province": "Maputo", "source_reference": "APIEX page; current territorial equivalence requires review"},
|
||||
"source": {"title": "Beluluane Industrial Park – Free Zone", "url": "https://apiex.gov.mz/beluluane-industrial-park-free-zone/", "diploma": "Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro", "article": "Act of creation; article and current legal effect pending review", "valid_from": "1999-10-12", "competent_entity": "Conselho de Ministros / APIEX source page"},
|
||||
"territories": [{"name": "MATOLA RIO", "type": "admin_post", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-BELULUANE-ZFI",
|
||||
"name": "Zona Franca Industrial de Beluluane",
|
||||
"name_en": "Beluluane Industrial Free Zone",
|
||||
"category": "zfi",
|
||||
"physical_category": "industrial_free_zone",
|
||||
"legal_regime": "zfi",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 55,
|
||||
"area_unit": "ha",
|
||||
"description": "Componente ZFI distinta dentro do complexo Beluluane. A APIEX indica cerca de 55 ha. Este registo não atribui elegibilidade ou incentivos a qualquer empresa.",
|
||||
"description_en": "A distinct IFZ component within the Beluluane complex. APIEX reports approximately 55 ha. This record does not establish eligibility or incentives for any company.",
|
||||
"aliases": ["Beluluane IFZ"],
|
||||
"activities": [],
|
||||
"parent_code": "MOZ-BELULUANE-PARK",
|
||||
"historical_territory": {"name": "Matola-Rio", "district": "Boane", "province": "Maputo", "source_reference": "APIEX page; current territorial equivalence requires review"},
|
||||
"source": {"title": "Beluluane Industrial Park – Free Zone", "url": "https://apiex.gov.mz/beluluane-industrial-park-free-zone/", "diploma": "Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro", "article": "Act of creation; article and current legal effect pending review", "valid_from": "1999-10-12", "competent_entity": "Conselho de Ministros / APIEX source page"},
|
||||
"territories": [{"name": "MATOLA RIO", "type": "admin_post", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-CRUSSE-JAMALI-ZEE",
|
||||
"name": "Zona de Estância de Turismo Integrado de Crusse e Jamali",
|
||||
"name_en": "Crusse and Jamali Integrated Tourism Resort Area",
|
||||
"category": "zee",
|
||||
"physical_category": "integrated_tourism_area",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 1750,
|
||||
"area_unit": "ha",
|
||||
"description": "Criada em regime de ZEE pelo Decreto n.º 47/2013, de 30 de Agosto. A fonte oficial indica 1.750 ha no Posto Administrativo de Matibane, Distrito de Mossuril, Nampula. A criação da zona não confirma que infra-estruturas ou oportunidades previstas estejam actualmente disponíveis.",
|
||||
"description_en": "Created under the SEZ regime by Decree no. 47/2013 of 30 August. The official source states 1,750 ha in Matibane Administrative Post, Mossuril District, Nampula. Establishment of the zone does not confirm that planned infrastructure or opportunities are currently available.",
|
||||
"aliases": ["Crusse-Jamali", "Crusse & Jamali"],
|
||||
"activities": [{"name": "Turismo integrado", "status": "potential", "source_reference": "Decreto n.º 47/2013; tourism scope in the creation description"}],
|
||||
"source": {"title": "Conselho de Ministros — BR n.º 70 de 30.08.13", "url": "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", "diploma": "Decreto n.º 47/2013, de 30 de Agosto", "article": "Act of creation; article reference pending review", "valid_from": "2013-08-30", "competent_entity": "Conselho de Ministros / Imprensa Nacional de Moçambique"},
|
||||
"territories": [{"name": "MATIBANE", "type": "admin_post", "scope_type": "legal_scope"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-MANGA-MUNGASSA-ZEE",
|
||||
"name": "Zona Económica Especial de Manga-Mungassa",
|
||||
"name_en": "Manga-Mungassa Special Economic Zone",
|
||||
"category": "zee",
|
||||
"physical_category": "economic_zone",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 217,
|
||||
"area_unit": "ha",
|
||||
"description": "A APIEX indica criação pelo Decreto n.º 22/2012, de 6 de Junho, localização na Cidade da Beira, Sofala, e área de 217 ha. A fonte refere sectores de potencial atracção; esses sectores não são tratados como actividades autorizadas nem como oportunidades cadastradas.",
|
||||
"description_en": "APIEX reports establishment by Decree no. 22/2012 of 6 June, location in Beira City, Sofala, and an area of 217 ha. Sectors described as investment potential are not treated as authorized activities or registered opportunities.",
|
||||
"aliases": ["Manga Mungassa ZEE"],
|
||||
"activities": [
|
||||
{"name": "Indústria", "status": "potential", "source_reference": "APIEX Manga-Mungassa page; potential sectors"},
|
||||
{"name": "Logística e transporte", "status": "potential", "source_reference": "APIEX Manga-Mungassa page; potential sectors"},
|
||||
{"name": "Hotelaria e serviços", "status": "potential", "source_reference": "APIEX Manga-Mungassa page; potential sectors"}
|
||||
],
|
||||
"source": {"title": "Manga-Mungassa Special Economic Zone – APIEX", "url": "https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/", "diploma": "Decreto n.º 22/2012, de 6 de Junho", "article": "Act of creation; article reference pending review", "valid_from": "2012-06-06", "competent_entity": "Conselho de Ministros / APIEX"},
|
||||
"territories": [{"name": "CIDADE DA BEIRA", "type": "district", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-MOCUBA-ZEE",
|
||||
"name": "Zona Económica Especial de Mocuba",
|
||||
"name_en": "Mocuba Special Economic Zone",
|
||||
"category": "zee",
|
||||
"physical_category": "economic_zone",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 10727,
|
||||
"area_unit": "km2",
|
||||
"description": "Criada pelo Decreto n.º 28/2014, de 6 de Junho. O INM e a APIEX indicam que abrange todo o Distrito de Mocuba e o Posto Administrativo de Munhamade, no Distrito de Lugela, Zambézia. A APIEX indica 10.727 km²; a unidade e a abrangência são preservadas sem as reduzir à cidade de Mocuba.",
|
||||
"description_en": "Created by Decree no. 28/2014 of 6 June. INM and APIEX state that it covers all of Mocuba District and Munhamade Administrative Post in Lugela District, Zambézia. APIEX reports 10,727 km²; the unit and scope are preserved without reducing it to Mocuba City.",
|
||||
"aliases": ["Mocuba ZEE"],
|
||||
"activities": [
|
||||
{"name": "Produção agrícola, pecuária, florestal e processamento", "status": "potential", "source_reference": "APIEX Mocuba SEZ page; stated attraction objectives"}
|
||||
],
|
||||
"source": {"title": "BR n.º 46 de 06.06.14 — Decreto n.º 28/2014", "url": "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", "diploma": "Decreto n.º 28/2014, de 6 de Junho", "article": "Act of creation; article reference pending review", "valid_from": "2014-06-06", "competent_entity": "Conselho de Ministros / Imprensa Nacional de Moçambique"},
|
||||
"territories": [
|
||||
{"name": "MOCUBA", "type": "district", "scope_type": "legal_scope"},
|
||||
{"name": "MUNHAMADE", "type": "admin_post", "scope_type": "legal_scope"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-MOCUBA-ZFI",
|
||||
"name": "Zona Franca Industrial de Mocuba",
|
||||
"name_en": "Mocuba Industrial Free Zone",
|
||||
"category": "zfi",
|
||||
"physical_category": "industrial_free_zone",
|
||||
"legal_regime": "zfi",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 50.58,
|
||||
"area_unit": "ha",
|
||||
"description": "Criada pelo Decreto n.º 29/2014, de 6 de Junho, e localizada na Cidade de Mocuba. A APIEX descreve esta ZFI como estando na fase de mobilização de recursos e do operador. É distinta da ZEE de Mocuba e não implica elegibilidade fiscal de empresas.",
|
||||
"description_en": "Created by Decree no. 29/2014 of 6 June and located in Mocuba City. APIEX describes this IFZ as mobilizing resources and an operator. It is distinct from Mocuba SEZ and does not imply tax eligibility for companies.",
|
||||
"aliases": ["Mocuba IFZ", "ZFI de Mocuba"],
|
||||
"activities": [],
|
||||
"source": {"title": "Mocuba IFZ – APIEX", "url": "https://apiex.gov.mz/zif/mocuba-free-industrial-zone/", "diploma": "Decreto n.º 29/2014, de 6 de Junho", "article": "Act of creation; article reference pending review", "valid_from": "2014-06-06", "competent_entity": "Conselho de Ministros / APIEX"},
|
||||
"territories": [{"name": "CIDADE DE MOCUBA", "type": "admin_post", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-NACALA-ZEE",
|
||||
"name": "Zona Económica Especial de Nacala",
|
||||
"name_en": "Nacala Special Economic Zone",
|
||||
"category": "zee",
|
||||
"physical_category": "economic_zone",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 1539,
|
||||
"area_unit": "km2",
|
||||
"description": "A APIEX indica criação pelo Decreto n.º 76/2007, de 18 de Dezembro, abrangência dos distritos de Nacala e Nacala-à-Velha e área de 1.539 km². Não se confunde com o Corredor de Nacala, porto ou outras zonas francas. Os números de projectos e empregos publicados na página não são importados como actuais por não terem data de referência confirmada.",
|
||||
"description_en": "APIEX reports establishment by Decree no. 76/2007 of 18 December, covering Nacala and Nacala-à-Velha districts, with an area of 1,539 km². It is distinct from the Nacala Corridor, port, and other free zones. Project and employment counts on the page are not imported as current because their reference date is not confirmed.",
|
||||
"aliases": ["ZEEN", "Nacala SEZ"],
|
||||
"activities": [
|
||||
{"name": "Logística e indústria", "status": "potential", "source_reference": "APIEX Nacala SEZ page; sectors identified as potential"},
|
||||
{"name": "Agro-processamento e indústria química", "status": "potential", "source_reference": "APIEX Nacala SEZ page; sectors identified as potential"}
|
||||
],
|
||||
"source": {"title": "Nacala Special Economic Zone – APIEX", "url": "https://apiex.gov.mz/nacala-special-economic-zone/", "diploma": "Decreto n.º 76/2007, de 18 de Dezembro", "article": "Act of creation; article reference pending review", "valid_from": "2007-12-18", "competent_entity": "Conselho de Ministros / APIEX"},
|
||||
"territories": [
|
||||
{"name": "NACALA-PORTO", "type": "district", "scope_type": "legal_scope"},
|
||||
{"name": "NACALA-VELHA", "source_name": "Nacala-à-Velha", "type": "district", "scope_type": "legal_scope"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation form draft RLS requires PostgreSQL.');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
ALTER POLICY simulation_form_drafts_read ON public.simulation_form_drafts
|
||||
USING (
|
||||
(owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
|
||||
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation form draft RLS requires PostgreSQL.');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
ALTER POLICY simulation_form_drafts_read ON public.simulation_form_drafts
|
||||
USING (
|
||||
deleted_at IS NULL AND (
|
||||
(owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
|
||||
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))
|
||||
)
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const ORIGINAL = [
|
||||
'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',
|
||||
];
|
||||
|
||||
private const ADDED = [
|
||||
'investment_project', 'business_partnership', 'financing_request', 'concession_opportunity', 'supply_opportunity',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->replaceConstraint([...self::ORIGINAL, ...self::ADDED]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$used = DB::table('existing_businesses')->whereIn('modality', self::ADDED)->exists();
|
||||
if ($used) {
|
||||
throw new RuntimeException('Cannot remove opportunity types while records use them.');
|
||||
}
|
||||
|
||||
$this->replaceConstraint(self::ORIGINAL);
|
||||
}
|
||||
|
||||
/** @param list<string> $values */
|
||||
private function replaceConstraint(array $values): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('This additive 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 '%forestry_license%'");
|
||||
if (count($constraints) !== 1) {
|
||||
throw new RuntimeException('Expected exactly one existing modality constraint; inspect the live schema before migrating.');
|
||||
}
|
||||
|
||||
$oldName = str_replace('"', '""', $constraints[0]->conname);
|
||||
DB::statement('ALTER TABLE existing_businesses DROP CONSTRAINT "'.$oldName.'"');
|
||||
|
||||
$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.'))');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Tax rule governance requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::create('simulation_tax_rule_versions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('rule_code', 64);
|
||||
$table->unsignedInteger('rule_version');
|
||||
$table->string('component', 24);
|
||||
$table->string('scope_key', 160);
|
||||
$table->date('valid_from');
|
||||
$table->date('valid_until')->nullable();
|
||||
$table->string('status', 24)->default('draft');
|
||||
$table->jsonb('rule_definition');
|
||||
$table->jsonb('parameters');
|
||||
$table->jsonb('rounding_policy');
|
||||
$table->foreignUuid('legal_source_id')->constrained('investment_legal_sources')->restrictOnDelete();
|
||||
$table->unsignedInteger('source_version_no');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->foreignId('author_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('approver_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('publisher_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->text('review_evidence')->nullable();
|
||||
$table->text('approval_evidence')->nullable();
|
||||
$table->text('publication_evidence')->nullable();
|
||||
$table->text('closure_evidence')->nullable();
|
||||
$table->timestampTz('submitted_at')->nullable();
|
||||
$table->timestampTz('reviewed_at')->nullable();
|
||||
$table->timestampTz('approved_at')->nullable();
|
||||
$table->timestampTz('published_at')->nullable();
|
||||
$table->timestampTz('suspended_at')->nullable();
|
||||
$table->timestampTz('archived_at')->nullable();
|
||||
$table->unsignedInteger('version_no')->default(1);
|
||||
$table->timestampsTz();
|
||||
$table->unique(['rule_code', 'scope_key', 'rule_version']);
|
||||
$table->index(['component', 'scope_key', 'status', 'valid_from'], 'tax_rule_resolution_idx');
|
||||
});
|
||||
DB::statement("ALTER TABLE simulation_tax_rule_versions ADD CONSTRAINT tax_rule_component_check CHECK(component IN ('IRPC','IVA','ISPC','IRPS','INSS','RETENCOES','DIREITOS_ADUANEIROS','ICE')), ADD CONSTRAINT tax_rule_status_check CHECK(status IN ('draft','submitted','reviewed','rejected','approved','published','suspended','archived')), ADD CONSTRAINT tax_rule_dates_check CHECK(valid_until IS NULL OR valid_until>=valid_from), ADD CONSTRAINT tax_rule_version_check CHECK(rule_version>0 AND version_no>0), ADD CONSTRAINT tax_rule_hash_check CHECK(source_sha256 ~ '^[0-9a-f]{64}$'), ADD CONSTRAINT tax_rule_stage_evidence_check CHECK(status NOT IN ('reviewed','rejected','approved','published','suspended','archived') OR (reviewer_id IS NOT NULL AND reviewer_id<>author_id AND submitted_at IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_evidence))>=20)), ADD CONSTRAINT tax_rule_approval_check CHECK(status NOT IN ('approved','published','suspended','archived') OR (approver_id IS NOT NULL AND approver_id<>author_id AND approver_id<>reviewer_id AND approved_at IS NOT NULL AND length(trim(approval_evidence))>=20)), ADD CONSTRAINT tax_rule_publication_check CHECK(status NOT IN ('published','suspended','archived') OR (publisher_id IS NOT NULL AND publisher_id<>author_id AND publisher_id<>reviewer_id AND publisher_id<>approver_id AND published_at IS NOT NULL AND length(trim(publication_evidence))>=20))");
|
||||
|
||||
Schema::create('simulation_tax_rule_events', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('rule_version_id')->constrained('simulation_tax_rule_versions')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_no');
|
||||
$table->foreignId('actor_user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('action', 32);
|
||||
$table->jsonb('snapshot');
|
||||
$table->char('sha256', 64);
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['rule_version_id', 'version_no']);
|
||||
});
|
||||
DB::statement("ALTER TABLE simulation_tax_rule_events ADD CONSTRAINT tax_rule_event_hash_check CHECK(sha256 ~ '^[0-9a-f]{64}$')");
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.investmoz_reject_tax_rule_event_change() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
|
||||
BEGIN RAISE EXCEPTION 'Tax rule governance events are append-only' USING ERRCODE = '42501'; END;
|
||||
$$;
|
||||
CREATE TRIGGER simulation_tax_rule_events_immutable BEFORE UPDATE OR DELETE ON public.simulation_tax_rule_events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.investmoz_reject_tax_rule_event_change();
|
||||
CREATE OR REPLACE FUNCTION public.investmoz_check_tax_rule_published_overlap() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'published' THEN
|
||||
PERFORM pg_advisory_xact_lock(hashtext(NEW.rule_code), hashtext(NEW.scope_key));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.simulation_tax_rule_versions existing
|
||||
WHERE existing.id <> NEW.id AND existing.status = 'published'
|
||||
AND existing.component = NEW.component AND existing.rule_code = NEW.rule_code
|
||||
AND existing.scope_key = NEW.scope_key
|
||||
AND existing.valid_from <= COALESCE(NEW.valid_until, 'infinity'::date)
|
||||
AND COALESCE(existing.valid_until, 'infinity'::date) >= NEW.valid_from
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Published tax rule effective period overlaps an existing version' USING ERRCODE = '23P01';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER simulation_tax_rule_no_published_overlap BEFORE INSERT OR UPDATE ON public.simulation_tax_rule_versions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.investmoz_check_tax_rule_published_overlap();
|
||||
ALTER TABLE public.simulation_tax_rule_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.simulation_tax_rule_versions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY simulation_tax_rule_versions_governance ON public.simulation_tax_rule_versions
|
||||
FOR ALL USING (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND NULLIF(current_setting('app.actor_user_id', true), '') IS NOT NULL)
|
||||
WITH CHECK (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND NULLIF(current_setting('app.actor_user_id', true), '') IS NOT NULL);
|
||||
ALTER TABLE public.simulation_tax_rule_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.simulation_tax_rule_events FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY simulation_tax_rule_events_governance ON public.simulation_tax_rule_events
|
||||
FOR SELECT USING (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND NULLIF(current_setting('app.actor_user_id', true), '') IS NOT NULL);
|
||||
CREATE POLICY simulation_tax_rule_events_insert ON public.simulation_tax_rule_events
|
||||
FOR INSERT WITH CHECK (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND actor_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::select("select set_config('app.actor_user_id', '-1', true), set_config('app.simulation_tax_governance', 'on', true)");
|
||||
try {
|
||||
if (Schema::hasTable('simulation_tax_rule_events') && DB::table('simulation_tax_rule_events')->exists()) {
|
||||
throw new RuntimeException('Tax rule governance history exists; preserve records before rollback.');
|
||||
}
|
||||
if (Schema::hasTable('simulation_tax_rule_versions') && DB::table('simulation_tax_rule_versions')->exists()) {
|
||||
throw new RuntimeException('Tax rule versions exist; preserve records before rollback.');
|
||||
}
|
||||
} finally {
|
||||
DB::select("select set_config('app.actor_user_id', '', true), set_config('app.simulation_tax_governance', '', true)");
|
||||
}
|
||||
|
||||
Schema::dropIfExists('simulation_tax_rule_events');
|
||||
Schema::dropIfExists('simulation_tax_rule_versions');
|
||||
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_check_tax_rule_published_overlap() RESTRICT');
|
||||
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_reject_tax_rule_event_change() RESTRICT');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS.');
|
||||
}
|
||||
|
||||
foreach (['cluster_projects', 'corridor_projects', 'corridor_clusters'] as $table) {
|
||||
DB::statement("ALTER TABLE public.{$table}
|
||||
ADD COLUMN association_type text NOT NULL DEFAULT 'potential',
|
||||
ADD COLUMN source_reference text,
|
||||
ADD COLUMN evidence_reference text,
|
||||
ADD COLUMN valid_from date,
|
||||
ADD COLUMN valid_until date,
|
||||
ADD COLUMN validation_status text NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN editor_id bigint,
|
||||
ADD COLUMN validator_id bigint,
|
||||
ADD CONSTRAINT imz_{$table}_association_type_ck CHECK (association_type IN ('official','influence','proximity','potential','historical')),
|
||||
ADD CONSTRAINT imz_{$table}_validation_status_ck CHECK (validation_status IN ('pending','validated','revoked')),
|
||||
ADD CONSTRAINT imz_{$table}_validity_ck CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until >= valid_from),
|
||||
ADD CONSTRAINT imz_{$table}_editor_fk FOREIGN KEY (editor_id) REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT imz_{$table}_validator_fk FOREIGN KEY (validator_id) REFERENCES public.users(id) ON DELETE RESTRICT");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('InvestMoz rollback requires PostgreSQL.');
|
||||
}
|
||||
|
||||
foreach (['cluster_projects', 'corridor_projects', 'corridor_clusters'] as $table) {
|
||||
$governedRows = DB::table($table)->where(function ($query): void {
|
||||
$query->whereNotNull('source_reference')->orWhereNotNull('evidence_reference')->orWhereNotNull('editor_id')
|
||||
->orWhereNotNull('validator_id')->orWhereNotNull('valid_from')->orWhereNotNull('valid_until')
|
||||
->orWhere('association_type', '!=', 'potential')->orWhere('validation_status', '!=', 'pending');
|
||||
})->count();
|
||||
if ($governedRows > 0) {
|
||||
throw new RuntimeException("Cannot roll back {$table} governance while {$governedRows} relation(s) contain review metadata.");
|
||||
}
|
||||
DB::statement("ALTER TABLE public.{$table}
|
||||
DROP CONSTRAINT imz_{$table}_validator_fk,
|
||||
DROP CONSTRAINT imz_{$table}_editor_fk,
|
||||
DROP CONSTRAINT imz_{$table}_validity_ck,
|
||||
DROP CONSTRAINT imz_{$table}_validation_status_ck,
|
||||
DROP CONSTRAINT imz_{$table}_association_type_ck,
|
||||
DROP COLUMN validator_id,
|
||||
DROP COLUMN editor_id,
|
||||
DROP COLUMN validation_status,
|
||||
DROP COLUMN valid_until,
|
||||
DROP COLUMN valid_from,
|
||||
DROP COLUMN evidence_reference,
|
||||
DROP COLUMN source_reference,
|
||||
DROP COLUMN association_type");
|
||||
}
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Published tax parameter reads require PostgreSQL RLS.');
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE POLICY simulation_tax_rule_versions_public_read
|
||||
ON public.simulation_tax_rule_versions
|
||||
FOR SELECT
|
||||
USING (
|
||||
status = 'published'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.investment_legal_sources AS source
|
||||
WHERE source.id = simulation_tax_rule_versions.legal_source_id
|
||||
AND source.validation_status = 'validated'
|
||||
AND source.version_no = simulation_tax_rule_versions.source_version_no
|
||||
AND source.valid_from <= simulation_tax_rule_versions.valid_from
|
||||
AND (
|
||||
source.valid_until IS NULL
|
||||
OR (
|
||||
simulation_tax_rule_versions.valid_until IS NOT NULL
|
||||
AND source.valid_until >= simulation_tax_rule_versions.valid_until
|
||||
)
|
||||
)
|
||||
AND (
|
||||
simulation_tax_rule_versions.valid_until IS NOT NULL
|
||||
OR source.valid_until IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP POLICY IF EXISTS simulation_tax_rule_versions_public_read ON public.simulation_tax_rule_versions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Land assistance requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::create('land_assistance_cases', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('reference', 40)->unique();
|
||||
$table->string('status', 32)->default('draft');
|
||||
$table->unsignedInteger('version')->default(1);
|
||||
$table->text('payload');
|
||||
$table->uuid('crm_submission_key')->unique();
|
||||
$table->foreignId('interest_request_id')->nullable()->unique()->constrained('interest_requests')->restrictOnDelete();
|
||||
$table->foreignId('assigned_to')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('support_requested_at')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->index(['user_id', 'status', 'updated_at']);
|
||||
$table->index(['status', 'updated_at']);
|
||||
});
|
||||
DB::statement("ALTER TABLE land_assistance_cases ADD CONSTRAINT land_assistance_status_check CHECK (status IN ('draft','support_requested','internal_review','waiting_applicant','referral_prepared','closed')), ADD CONSTRAINT land_assistance_version_check CHECK (version > 0)");
|
||||
|
||||
Schema::create('land_assistance_revisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignUuid('case_id')->constrained('land_assistance_cases')->restrictOnDelete();
|
||||
$table->unsignedInteger('version');
|
||||
$table->text('snapshot');
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['case_id', 'version']);
|
||||
});
|
||||
|
||||
Schema::create('land_assistance_events', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignUuid('case_id')->constrained('land_assistance_cases')->restrictOnDelete();
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('action', 40);
|
||||
$table->unsignedInteger('version');
|
||||
$table->jsonb('metadata')->default('{}');
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->index(['case_id', 'created_at']);
|
||||
});
|
||||
DB::statement("ALTER TABLE land_assistance_events ADD CONSTRAINT land_assistance_event_action_check CHECK (action IN ('draft_created','draft_updated','support_requested','status_changed','attachment_added','applicant_update_recorded'))");
|
||||
|
||||
Schema::create('land_assistance_documents', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('case_id')->constrained('land_assistance_cases')->restrictOnDelete();
|
||||
$table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
|
||||
$table->string('category', 40);
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['case_id', 'document_id']);
|
||||
});
|
||||
DB::statement("ALTER TABLE land_assistance_documents ADD CONSTRAINT land_assistance_document_category_check CHECK (category IN ('sketch','prior_right','exploration_plan','official_receipt','consultation_minutes','photo','other'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Land assistance rollback requires PostgreSQL.');
|
||||
}
|
||||
foreach (['land_assistance_documents', 'land_assistance_events', 'land_assistance_revisions', 'land_assistance_cases'] as $table) {
|
||||
if (DB::table($table)->exists()) {
|
||||
throw new RuntimeException('Preserve land assistance records before rollback.');
|
||||
}
|
||||
}
|
||||
Schema::dropIfExists('land_assistance_documents');
|
||||
Schema::dropIfExists('land_assistance_events');
|
||||
Schema::dropIfExists('land_assistance_revisions');
|
||||
Schema::dropIfExists('land_assistance_cases');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interest_requests', function ($table): void {
|
||||
$table->string('email', 150)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('interest_requests')->whereNull('email')->exists()) {
|
||||
throw new RuntimeException('Cannot restore the required email field while phone-only interest requests exist.');
|
||||
}
|
||||
|
||||
Schema::table('interest_requests', function ($table): void {
|
||||
$table->string('email', 150)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('opportunities', function (Blueprint $table): void {
|
||||
$table->string('geography_scope', 20)->default('unspecified');
|
||||
});
|
||||
DB::statement("ALTER TABLE opportunities ADD CONSTRAINT opportunities_geography_scope_check CHECK (geography_scope IN ('unspecified','specific','multiple','national','digital'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('opportunities')->where('geography_scope', '!=', 'unspecified')->exists()) {
|
||||
throw new RuntimeException('Preserve explicitly classified opportunity geography before rollback.');
|
||||
}
|
||||
DB::statement('ALTER TABLE opportunities DROP CONSTRAINT IF EXISTS opportunities_geography_scope_check');
|
||||
Schema::table('opportunities', function (Blueprint $table): void {
|
||||
$table->dropColumn('geography_scope');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation draft continuity requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::table('simulation_form_drafts', function ($table): void {
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->char('claimed_token_hash', 64)->nullable();
|
||||
});
|
||||
DB::table('simulation_form_drafts')->whereNull('owner_user_id')->whereNull('expires_at')->update([
|
||||
'expires_at' => DB::raw("GREATEST(created_at + interval '30 days', now() + interval '30 days')"),
|
||||
]);
|
||||
DB::statement('DROP INDEX IF EXISTS simulation_form_drafts_owner_active_unique');
|
||||
DB::statement('CREATE INDEX simulation_form_drafts_owner_active_idx ON public.simulation_form_drafts(owner_user_id, updated_at DESC) WHERE owner_user_id IS NOT NULL AND deleted_at IS NULL');
|
||||
DB::statement("CREATE UNIQUE INDEX simulation_form_drafts_claimed_token_unique ON public.simulation_form_drafts(claimed_token_hash) WHERE claimed_token_hash IS NOT NULL");
|
||||
|
||||
Schema::create('simulation_draft_business_links', function ($table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('draft_id')->constrained('simulation_form_drafts')->restrictOnDelete();
|
||||
$table->foreignUuid('business_id')->constrained('existing_businesses')->restrictOnDelete();
|
||||
$table->foreignId('owner_user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('linked_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('unlinked_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('linked_at');
|
||||
$table->timestampTz('unlinked_at')->nullable();
|
||||
$table->timestampsTz();
|
||||
});
|
||||
DB::statement('CREATE UNIQUE INDEX simulation_draft_business_links_active_unique ON public.simulation_draft_business_links(draft_id) WHERE unlinked_at IS NULL');
|
||||
DB::statement('CREATE INDEX simulation_draft_business_links_owner_idx ON public.simulation_draft_business_links(owner_user_id, linked_at DESC)');
|
||||
DB::unprepared(<<<'SQL'
|
||||
ALTER TABLE public.simulation_draft_business_links ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.simulation_draft_business_links FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY simulation_draft_business_links_read ON public.simulation_draft_business_links FOR SELECT
|
||||
USING (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL));
|
||||
CREATE POLICY simulation_draft_business_links_insert ON public.simulation_draft_business_links FOR INSERT
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND linked_by = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL)
|
||||
AND EXISTS (SELECT 1 FROM public.existing_businesses b WHERE b.id = business_id AND b.owner_user_id = owner_user_id AND b.deleted_at IS NULL));
|
||||
CREATE POLICY simulation_draft_business_links_update ON public.simulation_draft_business_links FOR UPDATE
|
||||
USING (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL))
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND linked_by = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL)
|
||||
AND (unlinked_at IS NOT NULL OR EXISTS (SELECT 1 FROM public.existing_businesses b WHERE b.id = business_id AND b.owner_user_id = owner_user_id AND b.deleted_at IS NULL)));
|
||||
CREATE POLICY simulation_draft_business_links_delete ON public.simulation_draft_business_links FOR DELETE
|
||||
USING (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL));
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation draft continuity rollback requires PostgreSQL.');
|
||||
}
|
||||
if (DB::table('simulation_draft_business_links')->exists()) {
|
||||
throw new RuntimeException('Cannot roll back while simulation-to-business link history exists.');
|
||||
}
|
||||
$hasMultipleOwnerDrafts = DB::table('simulation_form_drafts')->whereNotNull('owner_user_id')
|
||||
->whereNull('deleted_at')->select('owner_user_id')->groupBy('owner_user_id')->havingRaw('COUNT(*) > 1')->exists();
|
||||
if ($hasMultipleOwnerDrafts) {
|
||||
throw new RuntimeException('Cannot restore the single-draft-per-owner index while an account has multiple drafts.');
|
||||
}
|
||||
DB::statement('DROP TABLE public.simulation_draft_business_links');
|
||||
DB::statement('DROP INDEX IF EXISTS simulation_form_drafts_owner_active_idx');
|
||||
DB::statement('CREATE UNIQUE INDEX simulation_form_drafts_owner_active_unique ON public.simulation_form_drafts(owner_user_id) WHERE owner_user_id IS NOT NULL AND deleted_at IS NULL');
|
||||
Schema::table('simulation_form_drafts', function ($table): void {
|
||||
$table->dropColumn(['expires_at', 'claimed_token_hash']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->string('physical_category')->nullable();
|
||||
$table->string('legal_regime')->nullable();
|
||||
$table->string('operational_status')->default('unknown');
|
||||
$table->decimal('area_value', 18, 4)->nullable();
|
||||
$table->string('area_unit', 24)->nullable();
|
||||
$table->jsonb('aliases')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('activities')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('resources')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('historical_territory')->nullable();
|
||||
$table->date('data_as_of')->nullable();
|
||||
$table->date('sources_consulted_at')->nullable();
|
||||
$table->foreignUuid('parent_zone_id')->nullable()->constrained('investment_zones')->restrictOnDelete();
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_physical_category_check CHECK (physical_category IS NULL OR physical_category IN ('economic_zone','industrial_park','industrial_free_zone','integrated_tourism_area','other','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_legal_regime_check CHECK (legal_regime IS NULL OR legal_regime IN ('zee','zfi','general','pending','not_applicable'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_operational_status_check CHECK (operational_status IN ('operational','implementation','planned','suspended','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_area_check CHECK (area_value IS NULL OR area_value >= 0)");
|
||||
|
||||
Schema::create('investment_zone_locations', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete();
|
||||
$table->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
|
||||
$table->string('scope_type')->default('administrative');
|
||||
$table->text('source_reference');
|
||||
$table->foreignUuid('legal_source_id')->nullable()->constrained('investment_legal_sources')->restrictOnDelete();
|
||||
$table->date('valid_from')->nullable();
|
||||
$table->date('valid_until')->nullable();
|
||||
$table->string('validation_status')->default('pending');
|
||||
$table->foreignId('editor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_no')->default(1);
|
||||
$table->timestampsTz();
|
||||
$table->unique(['zone_id', 'geo_location_id', 'scope_type'], 'investment_zone_locations_identity_uq');
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_zone_locations ADD CONSTRAINT investment_zone_locations_scope_check CHECK(scope_type IN ('legal_scope','administrative','site','influence')), ADD CONSTRAINT investment_zone_locations_status_check CHECK(validation_status IN ('pending','validated','revoked')), ADD CONSTRAINT investment_zone_locations_dates_check CHECK(valid_until IS NULL OR valid_from IS NULL OR valid_until>=valid_from), ADD CONSTRAINT investment_zone_locations_review_check CHECK(validation_status<>'validated' OR (reviewer_id IS NOT NULL AND reviewer_id<>editor_id))");
|
||||
DB::statement('CREATE INDEX investment_zone_locations_geo_status_idx ON investment_zone_locations(geo_location_id, validation_status, zone_id)');
|
||||
|
||||
// Backfill only the already-recorded primary location; no location is inferred.
|
||||
DB::statement("INSERT INTO investment_zone_locations(id,zone_id,geo_location_id,scope_type,source_reference,validation_status,editor_id,reviewer_id,version_no,created_at,updated_at) SELECT gen_random_uuid(),id,primary_territory_id,'administrative','Legacy primary territory; source review required','pending',editor_id,NULL,1,now(),now() FROM investment_zones WHERE primary_territory_id IS NOT NULL ON CONFLICT(zone_id,geo_location_id,scope_type) DO NOTHING");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zone_locations')->exists() || DB::table('investment_zones')->where(function ($query): void {
|
||||
$query->whereNotNull('physical_category')->orWhereNotNull('legal_regime')->orWhere('operational_status', '<>', 'unknown')
|
||||
->orWhereNotNull('area_value')->orWhereNotNull('area_unit')->orWhereNotNull('parent_zone_id')->orWhereNotNull('historical_territory')
|
||||
->orWhereNotNull('data_as_of')->orWhereNotNull('sources_consulted_at')
|
||||
->orWhereRaw("aliases <> '[]'::jsonb OR activities <> '[]'::jsonb OR resources <> '[]'::jsonb");
|
||||
})->exists()) {
|
||||
throw new RuntimeException('Zone integration data exists; export and reconcile it before rollback.');
|
||||
}
|
||||
Schema::dropIfExists('investment_zone_locations');
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT IF EXISTS investment_zones_area_check, DROP CONSTRAINT IF EXISTS investment_zones_operational_status_check, DROP CONSTRAINT IF EXISTS investment_zones_legal_regime_check, DROP CONSTRAINT IF EXISTS investment_zones_physical_category_check');
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('parent_zone_id');
|
||||
$table->dropColumn(['physical_category','legal_regime','operational_status','area_value','area_unit','aliases','activities','resources','historical_territory','data_as_of','sources_consulted_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_zone_attachments', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete();
|
||||
$table->foreignUuid('document_id')->unique()->constrained('documents')->restrictOnDelete();
|
||||
$table->string('kind', 32);
|
||||
$table->string('caption', 255);
|
||||
$table->text('source_reference');
|
||||
$table->date('source_date')->nullable();
|
||||
$table->string('rights_status', 24)->default('pending');
|
||||
$table->text('rights_basis')->nullable();
|
||||
$table->string('personal_data_status', 24)->default('unknown');
|
||||
$table->string('review_status', 24)->default('pending');
|
||||
$table->foreignId('uploaded_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('reviewed_at')->nullable();
|
||||
$table->boolean('independent_review')->default(false);
|
||||
$table->boolean('publicly_selected')->default(false);
|
||||
$table->timestampTz('publicly_selected_at')->nullable();
|
||||
$table->foreignId('publicly_selected_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_no')->default(1);
|
||||
$table->timestampsTz();
|
||||
$table->index(['zone_id', 'review_status', 'publicly_selected']);
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_zone_attachments ADD CONSTRAINT investment_zone_attachments_kind_check CHECK (kind IN ('legal_instrument','map','photo','study','other')), ADD CONSTRAINT investment_zone_attachments_rights_check CHECK (rights_status IN ('pending','confirmed','restricted')), ADD CONSTRAINT investment_zone_attachments_personal_data_check CHECK (personal_data_status IN ('unknown','none','present')), ADD CONSTRAINT investment_zone_attachments_review_check CHECK (review_status IN ('pending','reviewed','rejected')), ADD CONSTRAINT investment_zone_attachments_review_metadata_check CHECK ((review_status='pending' AND reviewed_by IS NULL AND reviewed_at IS NULL) OR (review_status<>'pending' AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL)), ADD CONSTRAINT investment_zone_attachments_public_metadata_check CHECK ((NOT publicly_selected AND publicly_selected_at IS NULL AND publicly_selected_by IS NULL) OR (publicly_selected AND publicly_selected_at IS NOT NULL AND publicly_selected_by IS NOT NULL AND rights_status='confirmed' AND length(trim(coalesce(rights_basis,'')))>0 AND personal_data_status='none' AND review_status='reviewed'))");
|
||||
|
||||
Schema::create('investment_zone_attachment_events', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('attachment_id')->constrained('investment_zone_attachments')->restrictOnDelete();
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('action', 40);
|
||||
$table->unsignedInteger('version_no');
|
||||
$table->jsonb('metadata')->default(DB::raw("'{}'::jsonb"));
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->index(['attachment_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zone_attachments')->exists() || DB::table('investment_zone_attachment_events')->exists()) {
|
||||
throw new RuntimeException('Investment zone attachment data exists; export and reconcile it before rollback.');
|
||||
}
|
||||
|
||||
Schema::dropIfExists('investment_zone_attachment_events');
|
||||
Schema::dropIfExists('investment_zone_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_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 legal_source_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND length(trim(name)) > 0))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zones')->where('publication_status', 'published')->where(function ($query): void {
|
||||
$query->whereNull('operator_id')->orWhereNull('name_en')->orWhereNull('description_en');
|
||||
})->exists()) {
|
||||
throw new RuntimeException('Published zone data relies on the relaxed operator/language requirements; reconcile it before rollback.');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_publication_requirements_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_check CHECK (publication_status <> 'published' OR (validation_status='validated' AND category <> 'pending' AND legal_source_id IS NOT NULL AND operator_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND name_en IS NOT NULL AND description_en IS NOT NULL))");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->string('development_status', 32)->default('unknown');
|
||||
$table->string('availability_status', 32)->default('not_informed');
|
||||
$table->jsonb('area_components')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('source_evidence')->default(DB::raw("'[]'::jsonb"));
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_physical_category_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_physical_category_check CHECK (physical_category IS NULL OR physical_category IN ('economic_zone','industrial_park','agro_industrial_park','industrial_logistics_park','industrial_free_zone','integrated_tourism_area','other','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_development_status_check CHECK (development_status IN ('announced','planned','in_development','partially_operational','operational','suspended','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_availability_status_check CHECK (availability_status IN ('confirmed','unavailable','reported_unverified','not_informed'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_area_components_check CHECK (jsonb_typeof(area_components)='array')");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_source_evidence_check CHECK (jsonb_typeof(source_evidence)='array')");
|
||||
|
||||
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)))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zones')->where(function ($query): void {
|
||||
$query->where('development_status', '<>', 'unknown')->orWhere('availability_status', '<>', 'not_informed')
|
||||
->orWhereRaw("area_components <> '[]'::jsonb OR source_evidence <> '[]'::jsonb");
|
||||
})->exists()) {
|
||||
throw new RuntimeException('Zone development or source evidence data exists; export and reconcile it before rollback.');
|
||||
}
|
||||
|
||||
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 legal_source_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND length(trim(name)) > 0))");
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_source_evidence_check, DROP CONSTRAINT investment_zones_area_components_check, DROP CONSTRAINT investment_zones_availability_status_check, DROP CONSTRAINT investment_zones_development_status_check');
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_physical_category_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_physical_category_check CHECK (physical_category IS NULL OR physical_category IN ('economic_zone','industrial_park','industrial_free_zone','integrated_tourism_area','other','unknown'))");
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->dropColumn(['development_status', 'availability_status', 'area_components', 'source_evidence']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('The document catalogue requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->text('description')->nullable();
|
||||
$table->string('document_type', 40)->default('unknown');
|
||||
$table->string('document_number', 120)->nullable();
|
||||
$table->date('document_date')->nullable();
|
||||
$table->text('source_url')->nullable();
|
||||
$table->string('language', 12)->default('unknown');
|
||||
$table->string('version_label', 120)->nullable();
|
||||
$table->uuid('document_group_id')->nullable();
|
||||
$table->string('territorial_scope', 24)->default('unknown');
|
||||
$table->foreignUuid('territory_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
|
||||
$table->string('source_confirmation', 24)->default('unconfirmed');
|
||||
$table->string('legal_status', 24)->default('unknown');
|
||||
$table->string('rights_status', 24)->default('unconfirmed');
|
||||
$table->timestampTz('last_reviewed_at')->nullable();
|
||||
$table->string('document_identity_key', 64)->nullable();
|
||||
$table->foreignId('duplicate_of_resource_id')->nullable()->constrained('portal_resources')->restrictOnDelete();
|
||||
$table->unsignedInteger('metadata_version')->default(1);
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('last_reviewed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->boolean('independent_review')->default(false);
|
||||
$table->index(['document_type', 'language', 'published'], 'portal_resources_library_filters_idx');
|
||||
$table->index(['territory_id', 'territorial_scope'], 'portal_resources_library_territory_idx');
|
||||
});
|
||||
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->string('rights_status', 24)->default('unconfirmed');
|
||||
$table->string('original_source', 2000)->nullable();
|
||||
$table->index(['status', 'data_classification', 'sha256'], 'documents_library_visibility_hash_idx');
|
||||
});
|
||||
|
||||
Schema::create('portal_resource_links', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('resource_id')->constrained('portal_resources')->cascadeOnDelete();
|
||||
$table->string('target_type', 32);
|
||||
$table->string('target_key', 180);
|
||||
$table->string('relationship', 24)->default('applies_to');
|
||||
$table->string('applicability', 24)->default('support');
|
||||
$table->text('condition_text')->nullable();
|
||||
$table->text('basis_reference')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->unique(['resource_id', 'target_type', 'target_key', 'relationship'], 'portal_resource_links_identity_uq');
|
||||
$table->index(['target_type', 'target_key', 'applicability'], 'portal_resource_links_lookup_idx');
|
||||
});
|
||||
|
||||
Schema::create('portal_resource_revisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('resource_id')->constrained('portal_resources')->cascadeOnDelete();
|
||||
$table->unsignedInteger('revision_no');
|
||||
$table->string('change_type', 24);
|
||||
$table->jsonb('snapshot');
|
||||
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['resource_id', 'revision_no'], 'portal_resource_revisions_identity_uq');
|
||||
});
|
||||
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_document_type_check CHECK (document_type IN ('legislation','procedure','official_form','digital_adaptation','guidance_template','guide','study','reference_data','unknown'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_scope_check CHECK (territorial_scope IN ('national','provincial','district','locality','multiple','unknown'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_source_confirmation_check CHECK (source_confirmation IN ('unconfirmed','identified','official_confirmed'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_legal_status_check CHECK (legal_status IN ('not_applicable','in_force','historical','proposed','repealed','amended','unknown'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_rights_status_check CHECK (rights_status IN ('unconfirmed','authorized','restricted'))");
|
||||
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'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_relationship_check CHECK (relationship IN ('applies_to','supports','version_of','translation_of','amends','repeals','implements','cites','related_to'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_applicability_check CHECK (applicability IN ('required','conditional','support','optional_template','evidence'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_basis_check CHECK ((applicability NOT IN ('required','conditional') OR length(trim(coalesce(basis_reference,''))) >= 5) AND (applicability <> 'conditional' OR length(trim(coalesce(condition_text,''))) >= 5))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_metadata_version_check CHECK (metadata_version > 0)");
|
||||
DB::statement("ALTER TABLE portal_resource_revisions ADD CONSTRAINT portal_resource_revisions_change_type_check CHECK (change_type IN ('created','metadata_updated','published','unpublished'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('portal_resource_links')->exists() || DB::table('portal_resource_revisions')->exists()
|
||||
|| DB::table('portal_resources')->whereNotNull('document_identity_key')->orWhereNotNull('duplicate_of_resource_id')
|
||||
->orWhereNotNull('description')->orWhereNotNull('source_url')->orWhereNotNull('document_group_id')->exists()
|
||||
|| DB::table('documents')->where('rights_status', '!=', 'unconfirmed')->orWhereNotNull('original_source')->exists()) {
|
||||
throw new RuntimeException('Preserve document catalogue relationships and duplicate review data before rollback.');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE portal_resource_links DROP CONSTRAINT portal_resource_links_target_type_check, DROP CONSTRAINT portal_resource_links_relationship_check, DROP CONSTRAINT portal_resource_links_applicability_check, DROP CONSTRAINT portal_resource_links_basis_check');
|
||||
DB::statement('ALTER TABLE portal_resource_revisions DROP CONSTRAINT portal_resource_revisions_change_type_check');
|
||||
DB::statement('ALTER TABLE portal_resources DROP CONSTRAINT portal_resources_metadata_version_check');
|
||||
Schema::dropIfExists('portal_resource_revisions');
|
||||
Schema::dropIfExists('portal_resource_links');
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->dropIndex('documents_library_visibility_hash_idx');
|
||||
$table->dropColumn(['rights_status', 'original_source']);
|
||||
});
|
||||
DB::statement('ALTER TABLE portal_resources DROP CONSTRAINT portal_resources_document_type_check, DROP CONSTRAINT portal_resources_scope_check, DROP CONSTRAINT portal_resources_source_confirmation_check, DROP CONSTRAINT portal_resources_legal_status_check, DROP CONSTRAINT portal_resources_rights_status_check');
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->dropForeign(['territory_id']);
|
||||
$table->dropForeign(['duplicate_of_resource_id']);
|
||||
$table->dropForeign(['created_by']);
|
||||
$table->dropForeign(['last_reviewed_by']);
|
||||
$table->dropIndex('portal_resources_library_filters_idx');
|
||||
$table->dropIndex('portal_resources_library_territory_idx');
|
||||
$table->dropColumn(['description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label',
|
||||
'document_group_id', 'territorial_scope', 'territory_id', 'source_confirmation', 'legal_status', 'rights_status', 'last_reviewed_at',
|
||||
'document_identity_key', 'duplicate_of_resource_id', 'metadata_version', 'created_by', 'last_reviewed_by', 'independent_review']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->string('malware_scan_status', 20)->default('pending');
|
||||
$table->char('malware_scanned_sha256', 64)->nullable();
|
||||
$table->string('malware_engine', 80)->nullable();
|
||||
$table->string('malware_engine_version', 80)->nullable();
|
||||
$table->timestampTz('malware_signatures_at')->nullable();
|
||||
$table->timestampTz('malware_scanned_at')->nullable();
|
||||
$table->string('malware_failure_reason', 80)->nullable();
|
||||
$table->unsignedSmallInteger('malware_scan_attempts')->default(0);
|
||||
$table->index(['malware_scan_status', 'updated_at'], 'documents_malware_queue_idx');
|
||||
});
|
||||
DB::statement("ALTER TABLE documents ADD CONSTRAINT documents_malware_scan_status_check CHECK (malware_scan_status IN ('pending','scanning','clean','infected','inconclusive','failed'))");
|
||||
|
||||
Schema::create('document_malware_scans', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
|
||||
$table->char('file_sha256', 64);
|
||||
$table->string('result', 20);
|
||||
$table->string('reason_code', 80)->nullable();
|
||||
$table->string('engine', 80);
|
||||
$table->string('engine_version', 80);
|
||||
$table->timestampTz('signatures_at')->nullable();
|
||||
$table->timestampTz('started_at');
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
$table->unsignedInteger('duration_ms')->nullable();
|
||||
$table->unsignedInteger('file_size_bytes')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->index(['document_id', 'created_at'], 'document_malware_scans_history_idx');
|
||||
$table->index(['result', 'created_at'], 'document_malware_scans_result_idx');
|
||||
});
|
||||
DB::statement("ALTER TABLE document_malware_scans ADD CONSTRAINT document_malware_scans_result_check CHECK (result IN ('clean','infected','inconclusive','failed'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('document_malware_scans')->exists()) {
|
||||
throw new RuntimeException('Malware scan history exists; export and reconcile it before rollback.');
|
||||
}
|
||||
|
||||
Schema::dropIfExists('document_malware_scans');
|
||||
DB::statement('ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_malware_scan_status_check');
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->dropIndex('documents_malware_queue_idx');
|
||||
$table->dropColumn([
|
||||
'malware_scan_status', 'malware_scanned_sha256', 'malware_engine', 'malware_engine_version',
|
||||
'malware_signatures_at', 'malware_scanned_at', 'malware_failure_reason', 'malware_scan_attempts',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->foreignId('approved_by')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('portal_resources')->whereNull('approved_by')->exists()) {
|
||||
throw new RuntimeException('Private document drafts without an approver exist; preserve them before rollback.');
|
||||
}
|
||||
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->foreignId('approved_by')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->date('applicable_from')->nullable();
|
||||
$table->date('applicable_until')->nullable();
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_applicable_period_check CHECK (applicable_until IS NULL OR applicable_from IS NULL OR applicable_until >= applicable_from)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE portal_resources DROP CONSTRAINT IF EXISTS portal_resources_applicable_period_check');
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->dropColumn(['applicable_from', 'applicable_until']);
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user