diff --git a/app/Console/Commands/ImportDocumentBatch.php b/app/Console/Commands/ImportDocumentBatch.php index bc1784db..34b549f2 100644 --- a/app/Console/Commands/ImportDocumentBatch.php +++ b/app/Console/Commands/ImportDocumentBatch.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use App\Jobs\ScanDocumentForMalware; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -53,7 +54,9 @@ class ImportDocumentBatch extends Command ]; private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8'; + private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa'; + private const TRIO_HASHES = [ 'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941', 'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5', @@ -276,7 +279,9 @@ class ImportDocumentBatch extends Command return $failures > 0 ? self::FAILURE : self::SUCCESS; } - /** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */ + /** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. + * @param array $manifest + */ private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void { $allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES); @@ -310,7 +315,7 @@ class ImportDocumentBatch extends Command ->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true) ->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']); if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key) - || ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, hash_file('sha256', Storage::disk('local')->path($images[0]->storage_key)))) { + || ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, CheckedFile::sha256(Storage::disk('local')->path($images[0]->storage_key)))) { throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.'); } } @@ -344,8 +349,8 @@ class ImportDocumentBatch extends Command || ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441 || $verified === false || $verified > time() || $verified < time() - 86400 || ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '') - || ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path'])) - || ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))) { + || ! hash_equals($proof['sha256'] ?? '', CheckedFile::sha256($proof['dump_path'])) + || ! hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))) { throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.'); } } diff --git a/app/Console/Commands/RegisterFiscalQaDraft.php b/app/Console/Commands/RegisterFiscalQaDraft.php index 34b0cc47..326489d4 100644 --- a/app/Console/Commands/RegisterFiscalQaDraft.php +++ b/app/Console/Commands/RegisterFiscalQaDraft.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\SimulationTaxParameterResolver; use App\Services\SimulationTaxRuleExpression; use App\Services\SimulationTaxRuleGovernance; +use App\Support\CheckedFile; use App\Support\IsolatedQaDatabase; use Illuminate\Console\Command; @@ -24,8 +25,8 @@ class RegisterFiscalQaDraft extends Command $source = InvestmentLegalSource::findOrFail($this->argument('source')); abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022' && $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).'); - $payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR); - $actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf')); + $payload = json_decode(CheckedFile::read(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR); + $actualHash = CheckedFile::sha256(base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf')); abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.'); $payload['legal_source_id'] = (string) $source->id; app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']); diff --git a/app/Http/Controllers/Api/V1/HomeController.php b/app/Http/Controllers/Api/V1/HomeController.php index 9d63b829..068da669 100644 --- a/app/Http/Controllers/Api/V1/HomeController.php +++ b/app/Http/Controllers/Api/V1/HomeController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\Opportunity; -use App\Models\Sector; +use App\Services\SectorOrganization; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -21,7 +21,7 @@ class HomeController extends Controller $locale = $validated['locale'] ?? 'pt'; $data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () { - $sectors = \App\Services\SectorOrganization::catalog(); + $sectors = SectorOrganization::catalog(); $featured = Opportunity::with('sector:id,name,slug') ->published()->where('is_featured', true) diff --git a/app/Http/Controllers/Api/V1/InterestRequestController.php b/app/Http/Controllers/Api/V1/InterestRequestController.php index d59eeae5..a06cf795 100644 --- a/app/Http/Controllers/Api/V1/InterestRequestController.php +++ b/app/Http/Controllers/Api/V1/InterestRequestController.php @@ -8,11 +8,13 @@ use App\Http\Requests\StoreInterestRequestRequest; use App\Jobs\NotifyInterestReceived; use App\Models\ExistingBusiness; use App\Models\InterestRequest; +use App\Models\InvestmentSubmission; use App\Models\Opportunity; use App\Services\IdempotencyConflict; use App\Services\InterestSubmission; use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Validator; @@ -26,7 +28,7 @@ class InterestRequestController extends Controller $validated = [...$request->validated(), '_actor_id' => $request->user()->id]; $key = $request->header('Idempotency-Key'); abort_unless(is_string($key), 422, 'Identificador do envio em falta.'); - Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate(); + Validator::make(['idempotency_key' => $key], ['idempotency_key' => ['required', 'uuid']])->validate(); try { $interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) { @@ -78,19 +80,20 @@ class InterestRequestController extends Controller } } - $row = \App\Models\InvestmentSubmission::create([ + $row = InvestmentSubmission::create([ 'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id, 'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1, - 'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'], + 'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity->title ?? $business?->public_title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'], 'objective' => $validated['message'], 'sector' => $validated['sectorInterest'], 'country' => $validated['country'], 'existing_business_id' => $business?->id, 'modality' => $validated['modality'] ?? null], ]); - \Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([ + DB::table('investment_submission_events')->insert([ 'submission_id' => $row->id, 'actor_id' => $request->user()->id, 'action' => 'submitted', 'version' => 1, 'created_at' => now(), ]); $interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save(); + return $interest; }); @@ -99,7 +102,8 @@ class InterestRequestController extends Controller 'message' => 'Manifestação de interesse registada com sucesso.', 'data' => [ 'reference' => $interestRequest->reference, - 'status' => $interestRequest->status, + // The POST acknowledgement is stable on replay; current workflow state is in tracking. + 'status' => InterestStatus::Received, 'tracking_url' => '/investidor/manifestacoes', ], ], 201); diff --git a/app/Http/Controllers/Api/V1/ReferenceCatalogController.php b/app/Http/Controllers/Api/V1/ReferenceCatalogController.php index 6ac90a8a..ba0c8fa4 100644 --- a/app/Http/Controllers/Api/V1/ReferenceCatalogController.php +++ b/app/Http/Controllers/Api/V1/ReferenceCatalogController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Models\Sector; use App\Services\CountryCatalogue; +use App\Services\EconomicReferenceCatalog; +use App\Services\SectorOrganization; use Illuminate\Database\Query\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -44,7 +47,9 @@ class ReferenceCatalogController extends Controller $cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae'; $versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ'); - if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); } + if ($cae && ! app(EconomicReferenceCatalog::class)->available()) { + $versionQuery->whereRaw('false'); + } if (isset($data['version'])) { $versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']); } else { @@ -82,7 +87,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, (array) $row))->values(); if ($cae) { - $paths = app(\App\Services\EconomicReferenceCatalog::class)->activities()->keyBy('id'); + $paths = app(EconomicReferenceCatalog::class)->activities()->keyBy('id'); $items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']); } @@ -169,15 +174,17 @@ class ReferenceCatalogController extends Controller ->where('valid_from', '<=', today()->toDateString()) ->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString())) ->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']); - if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID); + if (empty($data['version'])) { + $query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID); + } if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) { $query->where('parent_id', $data['parent_id']); } else { $query->whereNull('parent_id'); } if (! empty($data['sector_id'])) { - $sector = \App\Models\Sector::find($data['sector_id']); - $query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); + $sector = Sector::whereKey($data['sector_id'])->first(); + $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); } if (! empty($data['q'])) { $this->search($query, $data['q'], ['code', 'label_pt_mz']); diff --git a/app/Http/Controllers/Api/V1/SectorController.php b/app/Http/Controllers/Api/V1/SectorController.php index c6a720e9..4655653c 100644 --- a/app/Http/Controllers/Api/V1/SectorController.php +++ b/app/Http/Controllers/Api/V1/SectorController.php @@ -4,13 +4,14 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\Sector; +use App\Services\SectorOrganization; use Illuminate\Http\JsonResponse; class SectorController extends Controller { public function index(): JsonResponse { - $sectors = \App\Services\SectorOrganization::catalog()->map(fn ($s) => [ + $sectors = SectorOrganization::catalog()->map(fn ($s) => [ 'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug, 'shortDescription' => $s->short_description, 'description' => $s->description, 'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count, @@ -25,7 +26,8 @@ class SectorController extends Controller $sector = Sector::with(['opportunities' => fn ($q) => $q->published()]) ->where('slug', $slug)->firstOrFail(); - $sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get()); - return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => \App\Services\SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]); + $sector->setRelation('opportunities', SectorOrganization::opportunities($sector)->get()); + + return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]); } } diff --git a/app/Http/Controllers/Api/V1/SimulationOfferDocumentController.php b/app/Http/Controllers/Api/V1/SimulationOfferDocumentController.php index dc2176c0..a643207a 100644 --- a/app/Http/Controllers/Api/V1/SimulationOfferDocumentController.php +++ b/app/Http/Controllers/Api/V1/SimulationOfferDocumentController.php @@ -5,14 +5,17 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Jobs\ScanDocumentForMalware; use App\Services\SimulationFormDrafts; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use stdClass; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class SimulationOfferDocumentController extends Controller { - public function store(Request $request, string $id, SimulationFormDrafts $drafts) + public function store(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse { abort_unless(Str::isUuid($id), 404); $drafts->find($id, $request); @@ -20,6 +23,7 @@ class SimulationOfferDocumentController extends Controller $request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]); $file = $request->file('file'); $hash = hash_file('sha256', $file->getRealPath()); + abort_unless(is_string($hash), 422, 'Não foi possível verificar o conteúdo do anexo.'); $existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first(); if ($existing) { return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store'); @@ -37,32 +41,37 @@ class SimulationOfferDocumentController extends Controller throw $e; } ScanDocumentForMalware::dispatch($documentId)->afterCommit(); + return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store'); } - public function index(Request $request, string $id, SimulationFormDrafts $drafts) + public function index(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse { abort_unless(Str::isUuid($id), 404); $drafts->find($id, $request); $documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get(); + return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store'); } - public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts) + public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts): BinaryFileResponse { abort_unless(Str::isUuid($id) && Str::isUuid($document), 404); $drafts->find($id, $request); $doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first(); - abort_unless($doc, 404); + abort_unless($doc !== null, 404); abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.'); $root = realpath(Storage::disk('local')->path('simulation-offers/'.$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); - abort_unless(hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 409, 'Conteúdo do anexo diverge do hash registado.'); + $hash = hash_file('sha256', $path); + abort_unless(is_string($hash) && hash_equals(trim($doc->sha256), $hash), 409, 'Conteúdo do anexo diverge do hash registado.'); + return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']); } - private function metadata(string $id, object $doc): array + /** @return array{id: string, draftId: string, name: string, sha256: string, scan: string, url: string} */ + private function metadata(string $id, stdClass $doc): array { return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256), 'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id]; diff --git a/app/Http/Controllers/CorridorAgribusinessController.php b/app/Http/Controllers/CorridorAgribusinessController.php index 0c132132..039e4221 100644 --- a/app/Http/Controllers/CorridorAgribusinessController.php +++ b/app/Http/Controllers/CorridorAgribusinessController.php @@ -7,9 +7,11 @@ use App\Services\CorridorAgribusinessOpportunities; use App\Services\OpportunityManagement; use App\Services\OpportunityPromotion; use App\Services\PortalProfileAccess; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; +use Inertia\Response; final class CorridorAgribusinessController extends Controller { @@ -18,31 +20,36 @@ final class CorridorAgribusinessController extends Controller return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR)); } - public function show(Request $request, CorridorAgribusinessOpportunities $service) + public function show(Request $request, CorridorAgribusinessOpportunities $service): Response { $access = app(PortalProfileAccess::class); abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403); $records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key'); - foreach ($records as $record) abort_unless($access->canView($request->user(), $record), 403); + foreach ($records as $record) { + abort_unless($access->canView($request->user(), $record), 403); + } + return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service), 'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'), 'entries' => array_map(function ($entry) use ($records) { $r = $records->get($entry['key']); + return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null]; }, $service->entries())]); } - public function register(Request $request, CorridorAgribusinessOpportunities $service) + public function register(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse { $data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']); abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.'); $access = app(PortalProfileAccess::class); $grant = $access->internalAssignment($request->user(), 'investment_project'); abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.'); + return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]); } - public function preview(Request $request, CorridorAgribusinessOpportunities $service) + public function preview(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse { $previews = []; foreach ($service->entries() as $entry) { @@ -52,21 +59,27 @@ final class CorridorAgribusinessController extends Controller $previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record, [...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])]; } + return response()->json(['previews' => $previews]); } - public function disclose(Request $request, CorridorAgribusinessOpportunities $service) + public function disclose(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse { $data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000', 'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct', 'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']); $expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all(); - $supplied = array_column($data['previews'], 'id'); sort($supplied); + $supplied = array_column($data['previews'], 'id'); + sort($supplied); abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.'); + return response()->json(DB::transaction(function () use ($request, $data) { $result = []; - foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'], - [...$preview, 'confirm' => true, 'reason' => $data['reason']]); + foreach ($data['previews'] as $preview) { + $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'], + [...$preview, 'confirm' => true, 'reason' => $data['reason']]); + } + return ['records' => $result]; })); } diff --git a/app/Http/Controllers/DocumentMalwareController.php b/app/Http/Controllers/DocumentMalwareController.php index 1cabce3a..26c3ebf3 100644 --- a/app/Http/Controllers/DocumentMalwareController.php +++ b/app/Http/Controllers/DocumentMalwareController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Jobs\ScanDocumentForMalware; +use App\Services\DocumentManagementAccess; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -12,7 +13,7 @@ class DocumentMalwareController extends Controller public function retry(Request $request, string $id): RedirectResponse { DB::transaction(function () use ($id, $request): void { - $document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first(); + $document = app(DocumentManagementAccess::class)->documents($request->user())->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.'); diff --git a/app/Http/Controllers/EditorialArticleController.php b/app/Http/Controllers/EditorialArticleController.php index ff040f4f..9729b891 100644 --- a/app/Http/Controllers/EditorialArticleController.php +++ b/app/Http/Controllers/EditorialArticleController.php @@ -2,63 +2,79 @@ namespace App\Http\Controllers; -use App\Services\VerifiedDocumentBinary; +use App\Services\DocumentManagementAccess; +use App\Services\EditorialInstallation; +use App\Services\EditorialReading; +use App\Services\PortalProfileAccess; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Database\Query\Builder; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Inertia\Inertia; +use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider; +use Laravel\Fortify\Events\TwoFactorAuthenticationFailed; +use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided; +use Laravel\Fortify\Fortify; +use Symfony\Component\HttpFoundation\Response; class EditorialArticleController extends Controller { - public function installation(Request $request, \App\Services\EditorialInstallation $installation) + public function installation(Request $request, EditorialInstallation $installation): Response { $plan = $installation->preview($request->user()); + return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store') - : response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store'); + : response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store'); } - public function install(Request $request, \App\Services\EditorialInstallation $installation) + public function install(Request $request, EditorialInstallation $installation): Response { $receipt = $installation->apply($request->user(), $request->all()); + return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store') - : response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store'); + : response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store'); } - public function showMfa(Request $request) + public function showMfa(Request $request): Response { return $this->confirmMfa($request); } - public function confirmMfa(Request $request) + public function confirmMfa(Request $request): Response { $actor = $request->user(); abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.'); - if (app(\App\Services\PortalProfileAccess::class)->mfa($actor)) { + if (app(PortalProfileAccess::class)->mfa($actor)) { return redirect()->route('editorial.installation'); } if ($request->isMethod('get')) { return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store'); } - abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']), 422, 'A conta e o destino são obtidos da sessão e do servidor.'); + abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']) !== [], 422, 'A conta e o destino são obtidos da sessão e do servidor.'); $data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']); try { $recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code'])); - $valid = $recovery !== null || (!empty($data['code']) && app(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify( - \Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code'] + $valid = $recovery !== null || (! empty($data['code']) && app(TwoFactorAuthenticationProvider::class)->verify( + Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code'] )); - } catch (\Illuminate\Contracts\Encryption\DecryptException $e) { + } catch (DecryptException $e) { $valid = false; } - if (!$valid) { - event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor)); + if (! $valid) { + event(new TwoFactorAuthenticationFailed($actor)); throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']); } if ($recovery !== null) { $actor->replaceRecoveryCode($recovery); } - event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor)); + event(new ValidTwoFactorAuthenticationCodeProvided($actor)); $request->session()->regenerate(); + return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store'); } @@ -67,7 +83,7 @@ class EditorialArticleController extends Controller abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.'); } - private function publicResources() + private function publicResources(): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized') @@ -75,28 +91,29 @@ class EditorialArticleController extends Controller ->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256'); } - public function edit(int $id) + public function edit(int $id): Response { $this->installed(); - app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); - $r = DB::table('portal_resources')->find($id); - abort_unless($r, 404); - $reading = app(\App\Services\EditorialReading::class); + app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); + $r = DB::table('portal_resources')->where('id', $id)->first(); + abort_unless($r instanceof \stdClass, 404); + $reading = app(EditorialReading::class); $original = $reading->original($r); $content = $reading->content($r, $original); - return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published], + + return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published], 'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']), - 'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request())->header('Cache-Control', 'private, no-store'); + 'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } - public function save(Request $request, int $id) + public function save(Request $request, int $id): RedirectResponse { $this->installed(); $data = $this->validateContent($request); DB::transaction(function () use ($request, $id, $data) { $r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); - abort_unless($r, 404); - app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); + abort_unless($r !== null, 404); + app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.'); if (! empty($data['image_resource_id'])) { abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); @@ -108,7 +125,7 @@ class EditorialArticleController extends Controller $content = $this->edition($r, $data); $revision = (int) $r->metadata_version + 1; DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]); - $resource = DB::table('portal_resources')->find($id); + $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' => 'metadata_updated', 'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => $request->user()->id, 'created_at' => now()]); DB::table('audit_events')->insert(['source_system' => 'portal-management', 'actor_user_id' => $request->user()->id, 'entity_type' => 'portal_resources', 'entity_id' => null, 'action' => 'editorial_updated', 'before_data' => json_encode(['record_id' => $id, 'metadata_version' => $r->metadata_version]), 'after_data' => json_encode(['record_id' => $id, 'metadata_version' => $revision, 'published' => false])]); @@ -117,46 +134,64 @@ class EditorialArticleController extends Controller return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.'); } + /** + * @return array + */ private function validateContent(Request $request): array { return $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'image_mode' => ['nullable', 'in:original,selected,none'], 'metadata_version' => ['required', 'integer', 'min:1']]); } - private function edition(object $resource, array $data): array + /** + * @param array $data + * @return array + */ + private function edition(\stdClass $resource, array $data): array { - $reading = app(\App\Services\EditorialReading::class); + $reading = app(EditorialReading::class); $original = $reading->original($resource); - $mode = $data['image_mode'] ?? (!empty($data['image_resource_id']) ? 'selected' : 'none'); - if ($mode === 'selected') abort_unless(!empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); - return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int)$data['image_resource_id'] : null, + $mode = $data['image_mode'] ?? (! empty($data['image_resource_id']) ? 'selected' : 'none'); + if ($mode === 'selected') { + abort_unless(! empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); + } + + return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int) $data['image_resource_id'] : null, 'preserve_original_html' => $original && trim($data['body']) === $reading->plain($original['content_html'])]; } - public function preview(Request $request, int $id) + public function preview(Request $request, int $id): Response { $this->installed(); - app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); - $resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404); - return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)]) - ->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow'); + app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); + $resource = DB::table('portal_resources')->where('id', $id)->first(); + abort_unless($resource instanceof \stdClass, 404); + + return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)]) + ->toResponse($request), static function (Response $response): void { + $response->headers->set('Cache-Control', 'private, no-store'); + $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); + }); } - public function previewContent(Request $request, int $id) + public function previewContent(Request $request, int $id): JsonResponse { $this->installed(); - app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); - $resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404); + app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); + $resource = DB::table('portal_resources')->where('id', $id)->first(); + abort_unless($resource instanceof \stdClass, 404); $data = $this->validateContent($request); - abort_unless((int)$resource->metadata_version === (int)$data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.'); - return response()->json(['article' => app(\App\Services\EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)]) + abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.'); + + return response()->json(['article' => app(EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)]) ->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow'); } - public function show(Request $request, int $id) + public function show(Request $request, int $id): Response { $this->installed(); - $resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']); - abort_unless($resource, 404); - return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store'); + $resource = app(EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']); + abort_unless($resource !== null, 404); + + return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource)])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } } diff --git a/app/Http/Controllers/ExistingBusinessController.php b/app/Http/Controllers/ExistingBusinessController.php index 31ab486d..afe6d4c4 100644 --- a/app/Http/Controllers/ExistingBusinessController.php +++ b/app/Http/Controllers/ExistingBusinessController.php @@ -11,6 +11,7 @@ use App\Services\CountryCatalogue; use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessIntake; use App\Services\ExistingBusinessObjects; +use App\Services\ExistingBusinessProfileCategory; use App\Services\ExistingBusinessVerification; use App\Services\InvestmentGeography; use App\Services\Mne\BusinessRepository; @@ -67,7 +68,7 @@ class ExistingBusinessController extends Controller $business = app(BusinessRepository::class)->find($id); Gate::authorize('view', $business); app(CompatibilityShadow::class)->schedule($id); - $props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), + $props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business), 'canTriage' => $request->user()->can('triage', $business), 'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business), diff --git a/app/Http/Controllers/InvestmentComplementController.php b/app/Http/Controllers/InvestmentComplementController.php index 3e4ceb1a..eb4ce6e0 100644 --- a/app/Http/Controllers/InvestmentComplementController.php +++ b/app/Http/Controllers/InvestmentComplementController.php @@ -3,25 +3,32 @@ namespace App\Http\Controllers; use App\Services\InvestmentComplements; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; +use Symfony\Component\HttpKernel\Exception\HttpException; final class InvestmentComplementController extends Controller { - public function index(Request $request, InvestmentComplements $service) + public function index(Request $request, InvestmentComplements $service): JsonResponse { - $data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid', + $data = $request->validate(['origin' => [Rule::requiredIf(! $request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid', 'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']); $items = []; $issues = []; foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) { try { $items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)]; - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) { - $status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404; - if (!isset($data['contexts']) || $status !== 404) throw $e; + } catch (ModelNotFoundException|HttpException $e) { + $status = $e instanceof HttpException ? $e->getStatusCode() : 404; + if (! isset($data['contexts']) || $status !== 404) { + throw $e; + } $issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.']; } } + return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store'); } } diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index a1d19208..5d95a6d4 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -4,9 +4,14 @@ namespace App\Http\Controllers; use App\Models\InvestmentDossier; use App\Models\Opportunity; +use App\Models\Sector; +use App\Services\CorridorAgribusinessOpportunities; use App\Services\InvestmentCatalogue; +use App\Services\InvestmentComplements; use App\Services\InvestmentGeography; use App\Services\InvestmentZones; +use App\Services\OpportunityCorridorExplorer; +use App\Services\SectorInvestment; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -26,9 +31,9 @@ class InvestmentDiscoveryController extends Controller } return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters), - 'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [], + 'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(OpportunityCorridorExplorer::class)->groups($filters) : [], 'territorySelection' => app(InvestmentGeography::class)->selection($filters), - 'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()), + 'guides' => app(SectorInvestment::class)->guides(Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()), 'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)), 'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [], 'territorial' => $this->territorial($filters)]); @@ -89,7 +94,7 @@ class InvestmentDiscoveryController extends Controller $data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]); $dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null; if ($dossierOrigin) { - $linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true); + $linked = in_array($record->id, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true); foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) { $linked = true; @@ -107,8 +112,8 @@ class InvestmentDiscoveryController extends Controller $returnFilters = $request->query(); unset($returnFilters['zone_id'], $returnFilters['from_dossier']); - return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin, - 'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector), + return Inertia::render('investment/detail', ['complements' => app(InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin, + 'guides' => app(SectorInvestment::class)->guides($record->sector), 'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null, 'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]); } diff --git a/app/Http/Controllers/InvestmentPortfolioController.php b/app/Http/Controllers/InvestmentPortfolioController.php index e9c32b67..14bb6f4b 100644 --- a/app/Http/Controllers/InvestmentPortfolioController.php +++ b/app/Http/Controllers/InvestmentPortfolioController.php @@ -6,6 +6,9 @@ use App\Models\InvestmentDossier; use App\Models\InvestmentSubmission; use App\Models\Opportunity; use App\Models\Project; +use App\Services\ClusterCompetitiveness; +use App\Services\CorridorAgribusinessOpportunities; +use App\Services\InvestmentComplements; use App\Services\InvestmentGeography; use App\Services\InvestmentRelations; use App\Services\InvestmentZones; @@ -112,7 +115,7 @@ class InvestmentPortfolioController extends Controller $opportunities = Opportunity::published()->where(function ($query) use ($record) { $query->whereRaw('false'); - $query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record)); + $query->orWhereIn('id', app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record)); foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) { if ($record->$foreign) { $query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id')); @@ -138,9 +141,9 @@ class InvestmentPortfolioController extends Controller ->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at']) : collect(); - return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones, + return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones, 'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions, - 'competitiveness' => $record->cluster_id ? app(\App\Services\ClusterCompetitiveness::class)->publicData($record->cluster_id) : null, + 'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null, 'geography' => app(InvestmentRelations::class)->dossier($record, $management), 'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]); } @@ -149,6 +152,7 @@ class InvestmentPortfolioController extends Controller { self::visible()->findOrFail($id); $data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]); + return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1'])); } diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index 092adda7..2a339888 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -8,6 +8,7 @@ use App\Models\EconomicCluster; use App\Models\InvestmentGap; use App\Models\Project; use App\Models\ValueChain; +use App\Services\ClusterCompetitiveness; use App\Services\InvestmentGeography; use App\Services\PortalAudit; use Illuminate\Http\RedirectResponse; @@ -50,7 +51,7 @@ 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']) : [], - 'competitiveness' => $structure === 'clusters' && $id ? app(\App\Services\ClusterCompetitiveness::class)->management($id) : null, + 'competitiveness' => $structure === 'clusters' && $id ? app(ClusterCompetitiveness::class)->management($id) : null, 'viewerId' => $request->user()->id, 'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects') ->join('projects', 'projects.id', '=', 'cluster_projects.project_id') diff --git a/app/Http/Controllers/InvestmentSubmissionController.php b/app/Http/Controllers/InvestmentSubmissionController.php index b067f532..9d1fcad8 100644 --- a/app/Http/Controllers/InvestmentSubmissionController.php +++ b/app/Http/Controllers/InvestmentSubmissionController.php @@ -10,6 +10,7 @@ use App\Models\Opportunity; use App\Services\CountryCatalogue; use App\Services\InterestSubmission; use App\Services\InvestmentCatalogue; +use App\Services\InvestmentConsumerScope; use App\Services\InvestmentWorkspace; use App\Services\InvestmentZones; use App\Services\InvestorContextConsumers; @@ -151,7 +152,7 @@ class InvestmentSubmissionController extends Controller public function bindContext(Request $request, string $id): JsonResponse { abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.'); - abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.'); + abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']) !== [], 422, 'Parâmetros de associação inválidos.'); $data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']); $result = DB::transaction(function () use ($request, $id, $data) { // Serialize all links for this submission before locking the selected case. @@ -250,8 +251,12 @@ class InvestmentSubmissionController extends Controller } $bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first(); - if ($bound) $data['payload'] = app(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting); - else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']); + if ($bound) { + $data['payload'] = app(InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting); + } else { + unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']); + } + return $data; } @@ -271,7 +276,7 @@ class InvestmentSubmissionController extends Controller $version = DB::transaction(function () use ($request, $id, $data) { $row = $this->owned($request, $id, true, true); abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.'); - $row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]); + $row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id' => (int) substr($data['payload']['context_opportunity'], 12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]); InvestmentSpecificNeed::where('submission_id', $id)->delete(); foreach ($data['needs'] ?? [] as $need) { InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); @@ -316,7 +321,7 @@ class InvestmentSubmissionController extends Controller } abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.'); $scopeKey = $data['payload']['context_opportunity'] ?? null; - $opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null); + $opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey, 12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null); if ($row->dossier_id) { InvestmentPortfolioController::visible()->findOrFail($row->dossier_id); } @@ -344,7 +349,7 @@ class InvestmentSubmissionController extends Controller 'incorporation_country_code' => $p['incorporation_country_code'] ?? null, ...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []), ...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]])); - $row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']); + $row->update(['opportunity_id' => $opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']); InvestmentSpecificNeed::where('submission_id', $id)->delete(); foreach ($data['needs'] ?? [] as $need) { InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php index b9c75bb4..a4fb336a 100644 --- a/app/Http/Controllers/InvestmentZoneController.php +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -5,14 +5,17 @@ namespace App\Http\Controllers; use App\Models\InvestmentDossier; use App\Models\InvestmentZone; use App\Models\Opportunity; +use App\Services\EconomicReferenceCatalog; use App\Services\InvestmentCatalogue; use App\Services\InvestmentGeography; use App\Services\InvestmentTerritorialTraceability; use App\Services\InvestmentZoneAccess; -use App\Services\InvestmentZones; +use App\Services\InvestmentZoneOperationalDetails; +use App\Services\InvestmentZoneOperationalDetailsInstallation; use App\Services\InvestmentZoneOperationalProfile; use App\Services\InvestmentZonePathContext; use App\Services\InvestmentZoneProfileInstallation; +use App\Services\InvestmentZones; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -33,7 +36,7 @@ class InvestmentZoneController extends Controller $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('operational_details_public',app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)); + $zone->setAttribute('operational_details_public', app(InvestmentZoneOperationalDetails::class)->publicData($zone)); $publicFields = ['operational_details_public', 'version_no', 'published_at', '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')) @@ -68,7 +71,7 @@ class InvestmentZoneController extends Controller }); $props = ['record' => $management ? app(InvestmentZoneOperationalProfile::class)->management($zone) : app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->only($publicFields)), 'source' => $source, 'operator' => $operator, 'management' => $management, 'profileAvailable' => $management && app(InvestmentZoneOperationalProfile::class)->installed(), - 'zoneContext' => !$management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id,(int)$zone->version_no) : null, + 'zoneContext' => ! $management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id, (int) $zone->version_no) : null, '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() : [], @@ -77,7 +80,7 @@ class InvestmentZoneController extends Controller 'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null, 'opportunities' => $opportunities, 'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [], - 'economicActivities' => $management ? app(\App\Services\EconomicReferenceCatalog::class)->activities() : [], + 'economicActivities' => $management ? app(EconomicReferenceCatalog::class)->activities() : [], '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, @@ -99,7 +102,9 @@ class InvestmentZoneController extends Controller '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); $publicColumns = ['primary_territory_id', 'version_no', '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']; - if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';} + if (app(InvestmentZoneOperationalDetails::class)->installed()) { + $publicColumns[] = 'operational_details'; + } $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(); @@ -113,11 +118,13 @@ class InvestmentZoneController extends Controller ->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'])); - if (! $management) { $zone->makeHidden('primary_territory_id'); } + if (! $management) { + $zone->makeHidden('primary_territory_id'); + } - return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)]; + return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()), 'operational_details_public' => app(InvestmentZoneOperationalDetails::class)->publicData($zone)]; }); - $props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path, + $props = ['detailsInstallation' => $management ? app(InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, '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); @@ -193,35 +200,35 @@ class InvestmentZoneController extends Controller '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'], - 'operational_details' => ['sometimes','array'], + 'operational_details' => ['sometimes', 'array'], 'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'], - 'activities.*.id' => ['nullable','uuid'], - 'activities.*.source_date' => ['nullable','date_format:Y-m-d'], - 'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'], - 'activities.*.scope' => ['nullable','in:inside,regional,unknown'], - 'activities.*.capacity_type' => ['nullable','string','max:120'], - 'activities.*.capacity_value' => ['nullable','numeric','min:0'], - 'activities.*.capacity_unit' => ['nullable','string','max:40'], - 'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'], - 'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'], - 'activities.*.support_document_ids' => ['sometimes','array','max:10'], + 'activities.*.id' => ['nullable', 'uuid'], + 'activities.*.source_date' => ['nullable', 'date_format:Y-m-d'], + 'activities.*.kind' => ['nullable', 'in:infrastructure,service,access,other'], + 'activities.*.scope' => ['nullable', 'in:inside,regional,unknown'], + 'activities.*.capacity_type' => ['nullable', 'string', 'max:120'], + 'activities.*.capacity_value' => ['nullable', 'numeric', 'min:0'], + 'activities.*.capacity_unit' => ['nullable', 'string', 'max:40'], + 'activities.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'], + 'activities.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'], + 'activities.*.support_document_ids' => ['sometimes', 'array', 'max:10'], 'activities.*.support_document_ids.*' => ['uuid'], - 'activities.*.private_note' => ['nullable','string','max:2000'], + 'activities.*.private_note' => ['nullable', 'string', 'max:2000'], '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:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'], - 'resources.*.id' => ['nullable','uuid'], - 'resources.*.source_date' => ['nullable','date_format:Y-m-d'], - 'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'], - 'resources.*.scope' => ['nullable','in:inside,regional,unknown'], - 'resources.*.capacity_type' => ['nullable','string','max:120'], - 'resources.*.capacity_value' => ['nullable','numeric','min:0'], - 'resources.*.capacity_unit' => ['nullable','string','max:40'], - 'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'], - 'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'], - 'resources.*.support_document_ids' => ['sometimes','array','max:10'], + 'resources.*.id' => ['nullable', 'uuid'], + 'resources.*.source_date' => ['nullable', 'date_format:Y-m-d'], + 'resources.*.kind' => ['nullable', 'in:infrastructure,service,access,other'], + 'resources.*.scope' => ['nullable', 'in:inside,regional,unknown'], + 'resources.*.capacity_type' => ['nullable', 'string', 'max:120'], + 'resources.*.capacity_value' => ['nullable', 'numeric', 'min:0'], + 'resources.*.capacity_unit' => ['nullable', 'string', 'max:40'], + 'resources.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'], + 'resources.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'], + 'resources.*.support_document_ids' => ['sometimes', 'array', 'max:10'], 'resources.*.support_document_ids.*' => ['uuid'], - 'resources.*.private_note' => ['nullable','string','max:2000'], + 'resources.*.private_note' => ['nullable', 'string', 'max:2000'], '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'], @@ -257,17 +264,26 @@ class InvestmentZoneController extends Controller $data['district'] = $path[1]->name ?? null; } - $saved=$zones->save($id, $data, $request->user()->id); + $saved = $zones->save($id, $data, $request->user()->id); + return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201); } public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse { - $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version'=>['required_if:action,reconcile','nullable','integer','min:1']]); - if($action==='reconcile'){ - $request->validate(['base_public_version'=>'required|integer|min:1']); - $saved=DB::transaction(function()use($id,$data,$request){$zone=InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();abort_unless($zone->category==='industrial_park',422);return app(InvestmentZoneOperationalProfile::class)->reconcile($zone,(int)$data['version_no'],(int)$data['base_public_version'],$request->user()->id);}); - }else{$saved=$action === 'publish' ? $zones->publish($id,$data['version_no'],$request->user()->id) : $zones->suspend($id,$data['version_no'],$request->user()->id);} + $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version' => ['required_if:action,reconcile', 'nullable', 'integer', 'min:1']]); + if ($action === 'reconcile') { + $request->validate(['base_public_version' => 'required|integer|min:1']); + $saved = DB::transaction(function () use ($id, $data, $request) { + $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); + abort_unless($zone->category === 'industrial_park', 422); + + return app(InvestmentZoneOperationalProfile::class)->reconcile($zone, (int) $data['version_no'], (int) $data['base_public_version'], $request->user()->id); + }); + } else { + $saved = $action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id) : $zones->suspend($id, $data['version_no'], $request->user()->id); + } + return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved)); } diff --git a/app/Http/Controllers/InvestorContextController.php b/app/Http/Controllers/InvestorContextController.php index 982faffa..e2a394ac 100644 --- a/app/Http/Controllers/InvestorContextController.php +++ b/app/Http/Controllers/InvestorContextController.php @@ -2,82 +2,106 @@ namespace App\Http\Controllers; +use App\Models\InvestmentSubmission; +use App\Services\InvestmentComplements; +use App\Services\InvestmentCorridorContext; use App\Services\InvestmentGeography; +use App\Services\InvestmentOpportunityChoices; use App\Services\InvestorContext; use App\Services\InvestorContextInstallation; use App\Services\InvestorPath; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Config; +use Illuminate\Validation\Rule; use Inertia\Inertia; +use Inertia\Response; final class InvestorContextController extends Controller { + /** + * @return array + */ private function entryComplements(Request $request): array { $data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40', 'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']); - if (empty($data['complement_origin'])) return []; - return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'], + if (empty($data['complement_origin'])) { + return []; + } + + return app(InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'], 'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]); } + /** + * @return array + */ private function entryChoices(Request $r, InvestorContext $context): array { $data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1', 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']); - $source = !empty($data['source']) ? $context->reference($data['source']) : null; + $source = ! empty($data['source']) ? $context->reference($data['source']) : null; $ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []); - abort_if(!empty($data['from_dossier']) && !$ids, 422, 'Escolha a oportunidade associada à ficha da carteira.'); + abort_if(! empty($data['from_dossier']) && ! $ids, 422, 'Escolha a oportunidade associada à ficha da carteira.'); $path = app(InvestmentGeography::class)->selection($data); - $input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids); - if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']]; + $input = array_map(fn ($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids); + if (! empty($data['from_dossier'])) { + $input[0]['portfolio_origins'] = [$data['from_dossier']]; + } foreach ($this->entryComplements($r) as $complement) { - $keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn($key) => str_starts_with($key, 'opportunity:')); + $keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn ($key) => str_starts_with($key, 'opportunity:')); foreach ($keys as $key) { - if (in_array($key, array_column($input, 'source'), true)) continue; + if (in_array($key, array_column($input, 'source'), true)) { + continue; + } $input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []), ...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])]; } } - return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true); + + return app(InvestmentOpportunityChoices::class)->resolve($input, [], true); } - public function current(Request $r, InvestorContext $context) + public function current(Request $r, InvestorContext $context): JsonResponse { return response()->json($context->current($r->user())); } - public function focus(Request $r, InvestorContext $context) + public function focus(Request $r, InvestorContext $context): JsonResponse { return response()->json($context->focus($r->user(), $r->all())); } - public function show(Request $r, string $id, InvestorContext $context) + public function show(Request $r, string $id, InvestorContext $context): JsonResponse { $data = $r->validate(['opportunity' => 'nullable|string|max:100']); + return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null)); } - public function options(Request $r, InvestorContext $context) + public function options(Request $r, InvestorContext $context): JsonResponse { $d = $r->validate(['q' => 'nullable|string|max:120']); return response()->json($context->options($d['q'] ?? '')); } - public function corridorOptions(Request $r) + public function corridorOptions(Request $r): JsonResponse { - $data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']); - $service = app(\App\Services\InvestmentCorridorContext::class); - return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(), + $data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']); + $service = app(InvestmentCorridorContext::class); + + return response()->json(['corridors' => collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values(), 'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]); } - public function saveDraft(Request $r, string $id, InvestorContext $context) + public function saveDraft(Request $r, string $id, InvestorContext $context): JsonResponse { return response()->json($context->saveDraft($r->user(), $id, $r->all())); } - public function edit(Request $r, string $id, InvestorContext $context) + public function edit(Request $r, string $id, InvestorContext $context): Response { $data = $context->view($r->user(), $id); abort_unless($data['can_write'], 403); @@ -85,72 +109,84 @@ final class InvestorContextController extends Controller $entries = $this->entryChoices($r, $context); $choices = $entries ?: null; $source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null; - if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]); - $explicitChoices=$choices; - $selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null; - $entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:''); - $entrySources=array_map($entryKey,$explicitChoices??[]); - if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices); + if ($choices === null && ($source['kind'] ?? '') === 'opportunity') { + $choices = app(InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]); + } + $explicitChoices = $choices; + $selectionKey = $r->validate(['selection_key' => 'nullable|uuid'])['selection_key'] ?? null; + $entryKey = fn ($c) => $c['opportunity']['key'].($selectionKey ? '@'.$selectionKey : ''); + $entrySources = array_map($entryKey, $explicitChoices ?? []); + if ($choices !== null) { + $choices = app(InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices); + } $draft = $context->draft($r->user(), $id); if ($draft && isset($draft['opportunity_choices'])) { - $recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true); - $new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true)); - $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new)); + $recovered = app(InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true); + $new = array_filter($explicitChoices ?? [], fn ($c) => ! in_array($entryKey($c), $draft['entry_sources'] ?? [], true)); + $choices = app(InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new)); } $differences = []; foreach ($explicitChoices ?? [] as $entry) { $kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']); - if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir']; + if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) { + $differences[] = ['title' => $entry['opportunity']['title'], 'saved' => $kept['territory']['title'] ?? 'Por definir', 'requested' => $entry['territory']['title'] ?? 'Por definir']; + } } - $complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []); + $complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []); $entryTerritory = null; if (($source['kind'] ?? null) === 'portfolio') { $path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid'])); - if ($path) $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id); + if ($path) { + $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id); + } } foreach ($this->entryComplements($r) as $entry) { $tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? '')); $entrySources[] = $tag; - if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all(); + if (! in_array($tag, $draft['entry_sources'] ?? [], true)) { + $complements = collect([...$complements, $entry])->unique('key')->values()->all(); + } } - return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]); + + return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]); } - public function preview(Request $r, string $id, InvestorContext $context) + public function preview(Request $r, string $id, InvestorContext $context): JsonResponse { return response()->json($context->preview($r->user(), $id, $r->all())); } - public function commit(Request $r, string $id, InvestorContext $context) + public function commit(Request $r, string $id, InvestorContext $context): JsonResponse { return response()->json($context->commit($r->user(), $id, $r->all())); } - public function start(Request $r, InvestorPath $path, InvestorContext $context) + public function start(Request $r, InvestorPath $path, InvestorContext $context): Response { $d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]); $ref = isset($d['source']) ? $context->reference($d['source']) : null; $submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null; $entrySubmission = null; if ($submissionId) { - $submission = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail(); + $submission = InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail(); abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.'); $entrySubmission = ['id' => $submission->id, 'version' => $submission->version]; } $choices = $this->entryChoices($r, $context); + return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]); } - public function installation(Request $r, InvestorContextInstallation $install) + public function installation(Request $r, InvestorContextInstallation $install): JsonResponse|Response { $plan = $install->preview($r->user()); return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]); } - public function install(Request $r, InvestorContextInstallation $install) + public function install(Request $r, InvestorContextInstallation $install): JsonResponse { - return response()->json($install->apply($r->user(),$r->all())); + return response()->json($install->apply($r->user(), $r->all())); } } diff --git a/app/Http/Controllers/InvestorPathController.php b/app/Http/Controllers/InvestorPathController.php index 687be094..774f06b8 100644 --- a/app/Http/Controllers/InvestorPathController.php +++ b/app/Http/Controllers/InvestorPathController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers; use App\Services\FintechInvestment; -use App\Services\InvestorPath; use App\Services\InvestmentZonePathContext; +use App\Services\InvestorPath; use App\Services\LegacyContent; use App\Services\SectorInvestment; use Illuminate\Http\JsonResponse; @@ -39,24 +39,29 @@ class InvestorPathController extends Controller return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Setor associado ao mesmo dossier.'); } - public function zoneStart(Request $request,string $zoneId,InvestorPath $path): JsonResponse|Response + public function zoneStart(Request $request, string $zoneId, InvestorPath $path): JsonResponse|Response { - abort_unless($path->available(),404); - $data=$request->validate(['consulted_public_version'=>'required|integer|min:1']); - $data=['contexts'=>$path->contexts($request->user()),'zoneSource'=>app(InvestmentZonePathContext::class)->context($zoneId,(int)$data['consulted_public_version'])]; - return $request->expectsJson()?response()->json($data):Inertia::render('investor-path/index',$data); + abort_unless($path->available(), 404); + $data = $request->validate(['consulted_public_version' => 'required|integer|min:1']); + $data = ['contexts' => $path->contexts($request->user()), 'zoneSource' => app(InvestmentZonePathContext::class)->context($zoneId, (int) $data['consulted_public_version'])]; + + return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data); } - public function zoneAttach(Request $request,string $id): JsonResponse|RedirectResponse + public function zoneAttach(Request $request, string $id): JsonResponse|RedirectResponse { - app(InvestmentZonePathContext::class)->attach($request->user(),$id,$request->all()); - return $request->expectsJson()?response()->json(['ok'=>true]):redirect('/investimentos/'.$id)->with('success','Referência do parque guardada; não constitui reserva ou autorização.'); + app(InvestmentZonePathContext::class)->attach($request->user(), $id, $request->all()); + + return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência do parque guardada; não constitui reserva ou autorização.'); } public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse { - $zone=$request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null; - if($zone){ $request->validate(['confirm'=>'required|accepted']); app(InvestmentZonePathContext::class)->context($zone['zone_id'],(int)$zone['consulted_public_version']); } + $zone = $request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null; + if ($zone) { + $request->validate(['confirm' => 'required|accepted']); + app(InvestmentZonePathContext::class)->context($zone['zone_id'], (int) $zone['consulted_public_version']); + } $sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null; if ($sector !== null) { app(SectorInvestment::class)->context($sector); @@ -79,7 +84,11 @@ class InvestorPathController extends Controller app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector]); } - if($zone){$case=$path->view($request->user(),$id)['case']; app(InvestmentZonePathContext::class)->attach($request->user(),$id,[...$zone,'version'=>$case['version'],'confirm'=>true]);} + if ($zone) { + $case = $path->view($request->user(), $id)['case']; + app(InvestmentZonePathContext::class)->attach($request->user(), $id, [...$zone, 'version' => $case['version'], 'confirm' => true]); + } + return $id; }); diff --git a/app/Http/Controllers/LandAssistanceController.php b/app/Http/Controllers/LandAssistanceController.php index 6c26e5e9..98b89a6c 100644 --- a/app/Http/Controllers/LandAssistanceController.php +++ b/app/Http/Controllers/LandAssistanceController.php @@ -377,6 +377,9 @@ class LandAssistanceController extends Controller 'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)]; } + /** + * @return array|null + */ private function dossierContext(LandAssistanceCase $case): ?array { $id = $case->payload['investor_path_case_id'] ?? null; diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php index 440fa69f..a3f92993 100644 --- a/app/Http/Controllers/OpportunityManagementController.php +++ b/app/Http/Controllers/OpportunityManagementController.php @@ -12,9 +12,12 @@ use App\Services\OpportunityManagement; use App\Services\OpportunityManagementInstallation; use App\Services\OpportunityMaturity; use App\Services\OpportunityProfileWorkflow; +use App\Services\OpportunityPromotion; use App\Services\OpportunityResources; use App\Services\OpportunityWorkflowTasks; use App\Services\PortalProfileAccess; +use App\Services\ProactiveInvestment; +use App\Services\SectorOrganization; use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -76,8 +79,8 @@ class OpportunityManagementController extends Controller $query->where('opportunity_category', $filters['category']); } if ($filters['sector_id'] ?? null) { - $sector = Sector::find($filters['sector_id']); - $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); + $sector = Sector::whereKey($filters['sector_id'])->first(); + $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); } if ($filters['geography_scope'] ?? null) { $query->where('geography_scope', $filters['geography_scope']); @@ -103,7 +106,7 @@ class OpportunityManagementController extends Controller $records = $query->orderByDesc('id')->paginate(20)->withQueryString(); $records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) { $r->setAttribute('can_edit', $access->canEdit($request->user(), $r)); - $r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r)); + $r->setAttribute('can_promote', app(OpportunityPromotion::class)->allowed($request->user(), $r)); $r->setAttribute('working_status', $access->state($r)); $r->setAttribute('publication', $r->publicationPresentation()); $r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title); @@ -131,7 +134,7 @@ class OpportunityManagementController extends Controller 'profileReauthenticationRequired' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false, 'publicationState' => $record?->publicationPresentation(), - 'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null, + 'promotionHref' => $record && app(OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null, 'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null, 'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false, 'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []], @@ -147,7 +150,7 @@ class OpportunityManagementController extends Controller Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? [])); $classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null; - return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(\App\Services\ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(\App\Services\ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES, + return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES, 'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS], 'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true), 'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]); diff --git a/app/Http/Controllers/OpportunityPromotionController.php b/app/Http/Controllers/OpportunityPromotionController.php index d183be6c..ab0b74cc 100644 --- a/app/Http/Controllers/OpportunityPromotionController.php +++ b/app/Http/Controllers/OpportunityPromotionController.php @@ -4,22 +4,24 @@ namespace App\Http\Controllers; use App\Models\Opportunity; use App\Services\OpportunityPromotion; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Inertia\Inertia; +use Inertia\Response; final class OpportunityPromotionController extends Controller { - public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response + public function show(Request $request, int $id, OpportunityPromotion $service): Response { return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id))); } - public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse + public function preview(Request $request, int $id, OpportunityPromotion $service): JsonResponse { return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all())); } - public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse + public function confirm(Request $request, int $id, OpportunityPromotion $service): JsonResponse { return response()->json($service->confirm($request->user(), $id, $request->all())); } diff --git a/app/Http/Controllers/OpportunityResourceController.php b/app/Http/Controllers/OpportunityResourceController.php index b8389e08..b5a66b57 100644 --- a/app/Http/Controllers/OpportunityResourceController.php +++ b/app/Http/Controllers/OpportunityResourceController.php @@ -6,9 +6,11 @@ use App\Models\Opportunity; use App\Services\CountryCatalogue; use App\Services\InvestmentGeography; use App\Services\OpportunityImageProcessor; +use App\Services\OpportunityLibrarySupport; use App\Services\OpportunityManagement; use App\Services\OpportunityResourceInstallation; use App\Services\OpportunityResources; +use App\Services\OpportunityTerritorialPresentation; use App\Services\OrganizationCountryInstallation; use App\Services\PortalProfileAccess; use App\Services\VerifiedDocumentBinary; @@ -98,12 +100,13 @@ class OpportunityResourceController extends Controller } } - return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]); + return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]); } public function reuseLibrary(Request $request, int $id): JsonResponse { - $row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all()); + $row = app(OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all()); + return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']); } @@ -150,15 +153,18 @@ class OpportunityResourceController extends Controller { abort_unless(in_array($kind, ['documents', 'images'], true), 404); $opportunity = Opportunity::findOrFail($id); - app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); + app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); abort_if($opportunity->status === 'archived', 404); $record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']); abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.'); abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.'); $support = DB::table($service->table($kind))->where('id', $resourceId)->first(); - app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record); - if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.'); + app(OpportunityLibrarySupport::class)->assertAvailable($support, $record); + if (request()->filled('source_sha256')) { + abort_unless(hash_equals(trim($record->sha256), (string) request('source_sha256')), 409, 'O original já não corresponde à versão consultada.'); + } + return app(VerifiedDocumentBinary::class)->respond($record); } diff --git a/app/Http/Controllers/PartnershipExchangeController.php b/app/Http/Controllers/PartnershipExchangeController.php index 20fc4a55..87fad76a 100644 --- a/app/Http/Controllers/PartnershipExchangeController.php +++ b/app/Http/Controllers/PartnershipExchangeController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Jobs\ScanDocumentForMalware; use App\Models\ExistingBusiness; +use App\Models\Sector; use App\Models\User; use App\Services\InvestmentCatalogue; use App\Services\InvestmentGeography; @@ -11,6 +12,7 @@ use App\Services\InvestmentRelations; use App\Services\PartnershipExchange; use App\Services\PartnershipMatching; use App\Services\PartnershipSources; +use App\Services\SectorOrganization; use App\Services\VerifiedDocumentBinary; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -45,9 +47,11 @@ class PartnershipExchangeController extends Controller foreach (['sector_id', 'partnership_type', 'kind'] as $field) { if (! empty($filters[$field])) { if ($field === 'sector_id') { - $sector = \App\Models\Sector::find($filters[$field]); - $query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); - } else { $query->where('p.'.$field, $filters[$field]); } + $sector = Sector::whereKey($filters[$field])->first(); + $query->whereIn('p.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); + } else { + $query->where('p.'.$field, $filters[$field]); + } } } if (! empty($filters['q'])) { @@ -393,7 +397,7 @@ class PartnershipExchangeController extends Controller } } - return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => \App\Models\Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), + return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']), 'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id) ->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []]; diff --git a/app/Http/Controllers/PortalManagementController.php b/app/Http/Controllers/PortalManagementController.php index a45312b8..e89f6a3e 100644 --- a/app/Http/Controllers/PortalManagementController.php +++ b/app/Http/Controllers/PortalManagementController.php @@ -15,6 +15,8 @@ use App\Services\InvestmentWorkspace; use App\Services\OpportunityManagement; use App\Services\PortalAudit; use App\Services\PortalProfileAccess; +use App\Services\ScopedPortalAdministration; +use App\Services\SectorOrganization; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -55,32 +57,38 @@ class PortalManagementController extends Controller private function interestAssignmentEtag(InterestRequest $interest): string { - return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR)); + return hash('sha256', json_encode([$interest->id, $interest->status, $interest->assigned_to, $interest->updated_at?->format('Y-m-d H:i:s.u')], JSON_THROW_ON_ERROR)); } public function interestAssignments(Request $request): Response|JsonResponse { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); - $rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at']) - ->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]); - $data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])]; - return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data); + abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); + $rows = InterestRequest::whereNotIn('status', ['cancelled', 'closed', 'refused', 'converted'])->orderBy('id')->get(['id', 'reference', 'status', 'assigned_to', 'updated_at']) + ->map(fn ($row) => ['id' => $row->id, 'protocol' => $row->reference, 'status' => $row->status, 'analyst_id' => $row->assigned_to, 'version' => 0, 'etag' => $this->interestAssignmentEtag($row), 'due_at' => null]); + $data = ['kind' => 'interest', 'requests' => $rows, 'analysts' => $this->interestAssignees()->get(['id', 'name'])]; + + return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data); } public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); - $data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']); - DB::transaction(function()use($request,$interest,$data){ - $row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); - if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']); - if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); - $owner=(int)($row->metadata['portal_user_id']??0); - abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.'); - PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id); - app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]); + abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); + $data = $request->validate(['etag' => 'required|string|size:64', 'analyst_id' => ['required', 'integer', Rule::in($this->interestAssignees()->pluck('id'))], 'reason' => 'required|string|min:10|max:2000'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 10 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.in' => 'Seleccione um responsável autorizado.']); + DB::transaction(function () use ($request, $interest, $data) { + $row = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); + if (! hash_equals($this->interestAssignmentEtag($row), $data['etag'])) { + throw ValidationException::withMessages(['etag' => 'A atribuição mudou. Recupere a lista actual e tente novamente.']); + } + if (in_array($row->status, ['cancelled', 'closed', 'refused', 'converted'], true)) { + throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); + } + $owner = (int) ($row->metadata['portal_user_id'] ?? 0); + abort_if($owner === (int) $data['analyst_id'] || InvestmentSubmission::where('interest_request_id', $row->id)->where('user_id', $data['analyst_id'])->exists(), 422, 'O interessado não pode acompanhar o seu próprio pedido como equipa.'); + PortalAudit::save($row, ['assigned_to' => (int) $data['analyst_id']], $request->user()->id); + app(PortalProfileAccess::class)->audit($request->user(), 'interest_assigned', ['interest_id' => $row->id, 'assigned_to' => (int) $data['analyst_id'], 'reason' => $data['reason']]); }); - return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.'); + + return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Responsável atribuído.'); } public function interests(Request $request): Response @@ -95,7 +103,7 @@ class PortalManagementController extends Controller $query = InterestRequest::query()->with('assignee:id,name')->select([ 'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at', ]); - app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user()); + app(ScopedPortalAdministration::class)->interestQuery($query, $request->user()); if ($country = $filters['country'] ?? null) { $values = app(CountryCatalogue::class)->filterValues($country); $query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values); @@ -142,7 +150,7 @@ class PortalManagementController extends Controller public function interest(InterestRequest $interest): Response { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); + abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); $history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id) ->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at']) ->map(function ($row) { @@ -170,7 +178,7 @@ class PortalManagementController extends Controller public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); + abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); $validated = $request->validate([ 'status' => ['required', Rule::in(self::STATES)], 'note' => ['nullable', 'string', 'min:10', 'max:2000'], @@ -198,7 +206,7 @@ class PortalManagementController extends Controller public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); + abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]); $this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent'); @@ -208,7 +216,7 @@ class PortalManagementController extends Controller public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403); + abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]); $this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest'); @@ -301,8 +309,8 @@ class PortalManagementController extends Controller } } if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) { - $sector = Sector::find($filters['sector_id']); - $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); + $sector = Sector::whereKey($filters['sector_id'])->first(); + $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); } return Inertia::render('management/catalog', [ @@ -319,7 +327,7 @@ class PortalManagementController extends Controller public function edit(string $resource, ?int $id = null): Response { $model = $resource === 'sectors' ? Sector::class : Opportunity::class; - $record = $id ? $model::findOrFail($id) : null; + $record = $id ? $model::whereKey($id)->firstOrFail() : null; return Inertia::render('management/editor', [ 'resource' => $resource, @@ -335,7 +343,7 @@ class PortalManagementController extends Controller throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']); } $model = $resource === 'sectors' ? Sector::class : Opportunity::class; - $record = $id ? $model::findOrFail($id) : new $model; + $record = $id ? $model::whereKey($id)->firstOrFail() : new $model; $rules = $resource === 'sectors' ? [ 'name' => ['required', 'string', 'max:150'], 'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)], diff --git a/app/Http/Controllers/PortalProfileController.php b/app/Http/Controllers/PortalProfileController.php index 6f069e26..7cb1fc5b 100644 --- a/app/Http/Controllers/PortalProfileController.php +++ b/app/Http/Controllers/PortalProfileController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Opportunity; +use App\Models\User; use App\Services\OpportunityCanonicalContract; use App\Services\OpportunityContacts; use App\Services\OpportunityManagement; @@ -51,6 +52,10 @@ class PortalProfileController extends Controller return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(), 'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(), 'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(), + 'staffAccounts' => User::query()->orderBy('name')->get(['id', 'name', 'email', 'email_verified_at', 'two_factor_confirmed_at'])->map(fn (User $user) => [ + 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, + 'verified' => $user->email_verified_at !== null, 'mfa' => $user->two_factor_confirmed_at !== null, + ]), 'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES, 'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES, 'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']), diff --git a/app/Http/Controllers/PortalProfileInstallationController.php b/app/Http/Controllers/PortalProfileInstallationController.php index eda0f0dd..597a58e6 100644 --- a/app/Http/Controllers/PortalProfileInstallationController.php +++ b/app/Http/Controllers/PortalProfileInstallationController.php @@ -30,6 +30,7 @@ class PortalProfileInstallationController extends Controller public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response { $plan = $installation->bootstrapPreview($request->user(), $id); + return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan); } diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php index 81bfc3f3..11717569 100644 --- a/app/Http/Controllers/PortalServicesController.php +++ b/app/Http/Controllers/PortalServicesController.php @@ -4,13 +4,20 @@ namespace App\Http\Controllers; use App\Jobs\ScanDocumentForMalware; use App\Models\InterestRequest; +use App\Models\Opportunity; use App\Models\Sector; use App\Services\CountryCatalogue; +use App\Services\DocumentManagementAccess; +use App\Services\EconomicReferenceCatalog; +use App\Services\EditorialReading; use App\Services\IdempotencyConflict; use App\Services\InterestSubmission; +use App\Services\InvestmentGeography; use App\Services\LegacyContent; -use App\Services\PortalProfileAccess; use App\Services\Malware\MalwareScanner; +use App\Services\PortalProfileAccess; +use App\Services\PublicDocumentPresentation; +use App\Services\SectorOrganization; use App\Services\VerifiedDocumentBinary; use App\Services\VirtualDesk; use Illuminate\Database\Query\Builder; @@ -18,6 +25,7 @@ use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; @@ -26,6 +34,7 @@ use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpKernel\Exception\HttpException; class PortalServicesController extends Controller { @@ -70,7 +79,7 @@ class PortalServicesController extends Controller ]); $props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()]; if ($page === 'sectors') { - $props['sectors'] = \App\Services\SectorOrganization::catalog(); + $props['sectors'] = SectorOrganization::catalog(); } if (in_array($page, ['documents', 'legislation'], true)) { $query = $this->resources(); @@ -116,7 +125,7 @@ class PortalServicesController extends Controller '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(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource)); + $pageResult->getCollection()->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($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'); @@ -188,7 +197,7 @@ class PortalServicesController extends Controller } $resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10) ->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']); - $resources->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource)); + $resources->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($resource)); return response()->json(['data' => $resources]); } @@ -266,7 +275,7 @@ class PortalServicesController extends Controller abort_unless($resource !== null, 404); $resource->territory_name = $resource->territory_id ? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null; - $resource = app(\App\Services\PublicDocumentPresentation::class)->resource($resource); + $resource = app(PublicDocumentPresentation::class)->resource($resource); $back = $request->query('back'); $backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//') ? $back : '/documentos'; @@ -289,7 +298,7 @@ class PortalServicesController extends Controller public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response { - $document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first(); + $document = app(DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first(); abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404); @@ -299,7 +308,7 @@ class PortalServicesController extends Controller public function manage(Request $request, MalwareScanner $scanner): Response { $managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']); - $access = app(\App\Services\DocumentManagementAccess::class); + $access = app(DocumentManagementAccess::class); if (! $access->full($request->user())) { $documents = $access->documents($request->user())->where('status', '!=', 'deleted'); $health = $scanner->health(); @@ -314,6 +323,7 @@ class PortalServicesController extends Controller $resource->links = $links->get($resource->id, collect())->values(); $resource->revisions = $revisions->get($resource->id, collect())->values(); }); + return Inertia::render('portal/manage', [ 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), 'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [], @@ -371,6 +381,9 @@ class PortalServicesController extends Controller 'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]); } + /** + * @param array $filters + */ private function filterManagedResources(Builder $query, array $filters): Builder { if (! empty($filters['q'])) { @@ -384,23 +397,28 @@ class PortalServicesController extends Controller $query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn'])); } } + return $query; } + /** + * @return array + */ private function contentAssociations(Request $request): array { - $access = app(\App\Services\DocumentManagementAccess::class); + $access = app(DocumentManagementAccess::class); $resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at') : ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null); $documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect(); + return [ - 'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']), - 'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']), - 'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']), + 'sector' => Sector::choices()->orderBy('name')->get(['id as value', 'name as label']), + 'territory' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']), + 'opportunity' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']), 'document' => $documents, 'procedure' => $documents->where('document_type', 'procedure')->values(), - 'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(), - 'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(), + 'stage' => collect(Config::array('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(), + 'activity' => app(EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(), ]; } @@ -445,7 +463,7 @@ class PortalServicesController extends Controller $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.'); - abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); + abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); $identity = $this->identityKey($data); if ($identity) { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]); @@ -474,11 +492,11 @@ class PortalServicesController extends Controller DB::transaction(function () use ($data, $request, $id, $expectedVersion): void { $resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); abort_unless($resource !== null, 404); - app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); + app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); 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.'); - abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); + abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); $identity = $this->identityKey($data); if ($identity) { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]); @@ -507,7 +525,7 @@ class PortalServicesController extends Controller public function moderate(Request $request, string $type, int $id): RedirectResponse { - $access = app(\App\Services\DocumentManagementAccess::class); + $access = app(DocumentManagementAccess::class); if (! $access->full($request->user())) { abort_unless($type === 'resources', 403); $access->assertEditableResource($request->user(), $id); @@ -519,7 +537,9 @@ 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') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); + if ($type === 'resources') { + app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); + } 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', @@ -832,17 +852,6 @@ class PortalServicesController extends Controller && DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists(); } - /** @return list */ - private function resourceLinkColumns(): array - { - $columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference']; - if (Schema::hasColumn('portal_resource_links', 'review_status')) { - $columns[] = 'review_status'; - } - - return $columns; - } - /** @return list */ private function territoryScopeIds(string $id): array { @@ -866,17 +875,23 @@ SQL, [$id]); return $ids ?: [$id]; } + private function articleUrl(int $id): ?string { - if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null; - $resource = DB::table('portal_resources')->find($id); - if (!$resource) return null; - if ($resource->editorial_content !== null) return '/artigos/'.$id; + if (! Schema::hasColumn('portal_resources', 'editorial_content')) { + return null; + } + $resource = DB::table('portal_resources')->where('id', $id)->first(); + if (! $resource) { + return null; + } + if ($resource->editorial_content !== null) { + return '/artigos/'.$id; + } try { - return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null; - } catch (\Symfony\Component\HttpKernel\Exception\HttpException) { + return app(EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null; + } catch (HttpException) { return null; } } - } diff --git a/app/Http/Controllers/ProactiveSupplyController.php b/app/Http/Controllers/ProactiveSupplyController.php index 20a225e2..4d9977e1 100644 --- a/app/Http/Controllers/ProactiveSupplyController.php +++ b/app/Http/Controllers/ProactiveSupplyController.php @@ -1,17 +1,19 @@ validate(['record_id'=>'nullable|integer|min:1']); - $record=isset($v['record_id'])?Opportunity::findOrFail($v['record_id']):null; - return response()->json($service->search($request->user(),$request->all(),$record))->header('Cache-Control','private, no-store'); + $v = $request->validate(['record_id' => 'nullable|integer|min:1']); + $record = isset($v['record_id']) ? Opportunity::whereKey($v['record_id'])->firstOrFail() : null; + + return response()->json($service->search($request->user(), $request->all(), $record))->header('Cache-Control', 'private, no-store'); } } diff --git a/app/Http/Controllers/ProcurementController.php b/app/Http/Controllers/ProcurementController.php index 47b1cc5d..ec4e2abd 100644 --- a/app/Http/Controllers/ProcurementController.php +++ b/app/Http/Controllers/ProcurementController.php @@ -3,22 +3,26 @@ namespace App\Http\Controllers; use App\Services\Procurement; +use Illuminate\Database\Query\Builder; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; +use Symfony\Component\HttpFoundation\Response; class ProcurementController extends Controller { private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END"; - private function query(Procurement $service) + private function query(Procurement $service): Builder { abort_unless($service->available(), 404); return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at'); } - public function index(Request $request, Procurement $service) + public function index(Request $request, Procurement $service): Response { $filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']); $filters = array_filter($filters, fn ($value) => $value !== null && $value !== ''); @@ -48,9 +52,12 @@ class ProcurementController extends Controller $options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values(); } - return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store'); + return tap(Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } + /** + * @return array + */ private function summary(Procurement $service, string $vid): array { $v = $service->publishedVersion($vid); @@ -58,62 +65,62 @@ class ProcurementController extends Controller return ['publication' => $v, 'state' => $service->state($v)]; } - public function show(Request $request, string $id, Procurement $service) + public function show(Request $request, string $id, Procurement $service): Response { return $this->detail($request, $id, $service, false); } - public function print(Request $request, string $id, Procurement $service) + public function print(Request $request, string $id, Procurement $service): Response { return $this->detail($request, $id, $service, true); } - private function detail(Request $request, string $id, Procurement $service, bool $print) + private function detail(Request $request, string $id, Procurement $service, bool $print): Response { $row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']); $filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']); - return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''), + return tap(Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''), 'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)), - 'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store'); + 'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } - public function manage(Request $request, Procurement $service) + public function manage(Request $request, Procurement $service): Response { $service->guard($request->user()); - return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store'); + return tap(Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } - public function edit(Request $request, Procurement $service, ?string $id = null) + public function edit(Request $request, Procurement $service, ?string $id = null): Response { $service->guard($request->user()); $r = $id ? $service->record($id) : null; - return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null, - 'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store'); + return tap(Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null, + 'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } - public function save(Request $request, Procurement $service, ?string $id = null) + public function save(Request $request, Procurement $service, ?string $id = null): RedirectResponse { $id = $service->save($request->user(), $id, $request->all()); return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.'); } - public function preview(Request $request, string $id, Procurement $service) + public function preview(Request $request, string $id, Procurement $service): JsonResponse { return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store'); } - public function publish(Request $request, string $id, Procurement $service) + public function publish(Request $request, string $id, Procurement $service): RedirectResponse { $service->publish($request->user(), $id, $request->all()); return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.'); } - public function watch(Request $request, string $id, Procurement $service) + public function watch(Request $request, string $id, Procurement $service): RedirectResponse { $row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']); DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]); @@ -121,7 +128,7 @@ class ProcurementController extends Controller return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.'); } - public function unwatch(Request $request, string $id, Procurement $service) + public function unwatch(Request $request, string $id, Procurement $service): RedirectResponse { abort_unless($service->available(), 404); DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete(); @@ -129,10 +136,10 @@ class ProcurementController extends Controller return back(); } - public function watched(Request $request, Procurement $service) + public function watched(Request $request, Procurement $service): Response { $rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]); - return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store'); + return tap(Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } } diff --git a/app/Http/Controllers/PublicBusinessOpportunityController.php b/app/Http/Controllers/PublicBusinessOpportunityController.php index 05da0a60..0f686587 100644 --- a/app/Http/Controllers/PublicBusinessOpportunityController.php +++ b/app/Http/Controllers/PublicBusinessOpportunityController.php @@ -4,11 +4,13 @@ namespace App\Http\Controllers; use App\Http\Resources\PublicBusinessOpportunityResource; use App\Models\ExistingBusiness; +use App\Models\Sector; use App\Services\CountryCatalogue; -use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessClassification; -use App\Services\OpportunityClassification; +use App\Services\ExistingBusinessDrafts; use App\Services\InvestmentGeography; +use App\Services\OpportunityClassification; +use App\Services\SectorOrganization; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -43,8 +45,8 @@ class PublicBusinessOpportunityController extends Controller ->orWhereLike('existing_businesses.objective', '%'.$term.'%')); } if (! empty($filters['sector'])) { - $sector = \App\Models\Sector::find($filters['sector']); - $query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); + $sector = Sector::whereKey($filters['sector'])->first(); + $query->whereIn('existing_businesses.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); } if (! empty($filters['modality'])) { $query->where('existing_businesses.modality', $filters['modality']); @@ -60,6 +62,9 @@ class PublicBusinessOpportunityController extends Controller 'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]); } + /** + * @return array + */ private function filters(Request $request): array { return $request->validate([ diff --git a/app/Http/Controllers/PublicGeographyController.php b/app/Http/Controllers/PublicGeographyController.php index a0ebe60c..56574439 100644 --- a/app/Http/Controllers/PublicGeographyController.php +++ b/app/Http/Controllers/PublicGeographyController.php @@ -2,21 +2,26 @@ namespace App\Http\Controllers; +use App\Services\InvestmentCorridorContext; use App\Services\InvestmentGeography; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; class PublicGeographyController extends Controller { public function options(Request $request, InvestmentGeography $geography): JsonResponse { - $data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], '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']]); + $data = $request->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], '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['corridor'])) { - $corridors = app(\App\Services\InvestmentCorridorContext::class); - if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id')); - elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.'); + if (! empty($data['corridor'])) { + $corridors = app(InvestmentCorridorContext::class); + if ($data['type'] === 'province') { + $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id')); + } elseif (! $corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) { + abort(422, 'O território não pertence ao corredor escolhido.'); + } } if (! empty($data['id'])) { $query->where('id', $data['id']); diff --git a/app/Http/Controllers/PublicPortalController.php b/app/Http/Controllers/PublicPortalController.php index a38ff0d2..e59b41e0 100644 --- a/app/Http/Controllers/PublicPortalController.php +++ b/app/Http/Controllers/PublicPortalController.php @@ -6,6 +6,8 @@ use App\Models\Opportunity; use App\Models\Sector; use App\Services\CountryCatalogue; use App\Services\SectorInvestment; +use App\Services\SectorOrganization; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -19,10 +21,14 @@ class PublicPortalController extends Controller return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']); } - public function sector(string $slug): Response|\Illuminate\Http\RedirectResponse + public function sector(string $slug): Response|RedirectResponse { - if (in_array($slug, ['energia', 'fintech'], true)) return redirect(\App\Services\SectorOrganization::href($slug), 301); - if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech'; + if (in_array($slug, ['energia', 'fintech'], true)) { + return redirect(SectorOrganization::href($slug), 301); + } + if ($slug === 'mercado-financeiro/fintech') { + $slug = 'fintech'; + } $sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail(); return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]); diff --git a/app/Http/Controllers/SectorReconciliationController.php b/app/Http/Controllers/SectorReconciliationController.php index 0b8cca24..12478129 100644 --- a/app/Http/Controllers/SectorReconciliationController.php +++ b/app/Http/Controllers/SectorReconciliationController.php @@ -1,20 +1,24 @@ preview($request); + return $request->expectsJson() ? response()->json($plan)->header('Cache-Control', 'private, no-store') - : response()->view('management.sector-reconciliation', ['plan' => $plan, 'operationKey' => (string)Str::uuid()])->header('Cache-Control', 'private, no-store'); + : response()->view('management.sector-reconciliation', ['plan' => $plan, 'operationKey' => (string) Str::uuid()])->header('Cache-Control', 'private, no-store'); } - public function apply(Request $request, SectorReconciliationExecution $execution) + public function apply(Request $request, SectorReconciliationExecution $execution): JsonResponse { return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store'); } diff --git a/app/Http/Controllers/VirtualDeskController.php b/app/Http/Controllers/VirtualDeskController.php index 8db723a6..ca8a8d87 100644 --- a/app/Http/Controllers/VirtualDeskController.php +++ b/app/Http/Controllers/VirtualDeskController.php @@ -10,6 +10,7 @@ use App\Services\CountryCatalogue; use App\Services\InstitutionalLinkMonitor; use App\Services\InvestmentGeography; use App\Services\InvestorContextConsumers; +use App\Services\ScopedPortalAdministration; use App\Services\VerifiedDocumentBinary; use App\Services\VirtualDesk; use Illuminate\Http\JsonResponse; @@ -21,6 +22,7 @@ use Illuminate\Support\Facades\Crypt; 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; @@ -148,7 +150,7 @@ class VirtualDeskController extends Controller $data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)], 'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths, 'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true), - 'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id, + 'owner' => $desk->owns($request->user(), $case), 'manager' => app(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id, 'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []), 'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(), 'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body), @@ -178,24 +180,29 @@ class VirtualDeskController extends Controller public function assignments(Request $request): JsonResponse|Response { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); + abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); // Triage projection deliberately excludes payloads, documents, investor identity and case titles. - $data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']), - 'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])]; - return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data); + $data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed'])->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']), + 'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])]; + + return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data); } public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse { - abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403); - $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']); + abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); + $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 3 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.exists' => 'Seleccione um responsável com conta disponível.']); DB::transaction(function () use ($request, $id, $desk, $data) { - $row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first(); - abort_unless($row,404); - $case = DB::table('desk_cases')->where('id',$row->case_id)->first(); - abort_unless($case,404); - if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']); - if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); + $row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first(); + abort_unless($row !== null, 404); + $case = DB::table('desk_cases')->where('id', $row->case_id)->first(); + abort_unless($case !== null, 404); + if ((int) $row->version !== (int) $data['version']) { + throw ValidationException::withMessages(['version' => 'Este pedido foi actualizado. Recupere a lista e tente novamente.']); + } + if (in_array($row->status, ['draft', 'cancelled', 'closed'], true)) { + throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); + } $analyst = User::where('id', $data['analyst_id'])->firstOrFail(); abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.'); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.'); diff --git a/app/Http/Middleware/EnsureDocumentManagement.php b/app/Http/Middleware/EnsureDocumentManagement.php index 26af0cb8..ff75a687 100644 --- a/app/Http/Middleware/EnsureDocumentManagement.php +++ b/app/Http/Middleware/EnsureDocumentManagement.php @@ -12,7 +12,7 @@ class EnsureDocumentManagement public function handle(Request $request, Closure $next): Response { $actor = $request->user(); - abort_unless($actor, 403); + abort_unless($actor !== null, 403); $access = app(DocumentManagementAccess::class); if ($access->full($actor)) { return $next($request); // Preserve existing management controls. diff --git a/app/Http/Middleware/EnsureHomologationAccess.php b/app/Http/Middleware/EnsureHomologationAccess.php index acb9b68d..d95bc466 100644 --- a/app/Http/Middleware/EnsureHomologationAccess.php +++ b/app/Http/Middleware/EnsureHomologationAccess.php @@ -1,37 +1,50 @@ server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403, - 'Este ambiente de homologação tem acesso reservado à Tempo.'); - $response=$next($request); - $response->headers->set('Cache-Control','private, no-store'); - $response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive'); - return $response; - } - abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.'); - // Compatibility until an independent perimeter is explicitly configured. - if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*', - 'email/verify','email/verify/*','email/verification-notification','two-factor-challenge', - 'user/confirm-password','user/confirmed-password-status','user/two-factor-*', - 'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request); - $actor=$request->user(); - if (!$actor) return $request->expectsJson() - ? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401) - : redirect()->guest(route('login')); - abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403, - 'Esta homologação está reservada às contas indicadas pela Tempo.'); - $response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response; - } + public function handle(Request $request, Closure $next): Response + { + if (! config('portal.homologation_only', false)) { + return $next($request); + } + $mode = config('portal.homologation_access_mode', 'reviewers'); + if ($mode === 'perimeter') { + // Set only by the trusted server after its environment-access check. + // A client HTTP_INVESTMOZ_* header is deliberately not accepted. + abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS') === 'granted', 403, + 'Este ambiente de homologação tem acesso reservado à Tempo.'); + $response = $next($request); + $response->headers->set('Cache-Control', 'private, no-store'); + $response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive'); + + return $response; + } + abort_unless($mode === 'reviewers', 503, 'A reserva da homologação não está configurada.'); + // Compatibility until an independent perimeter is explicitly configured. + if ($request->is('login', 'logout', 'register', 'forgot-password', 'reset-password', 'reset-password/*', + 'email/verify', 'email/verify/*', 'email/verification-notification', 'two-factor-challenge', + 'user/confirm-password', 'user/confirmed-password-status', 'user/two-factor-*', + 'settings/security', 'settings/password', 'user/password', 'passkeys/*', 'user/passkeys/*', 'up')) { + return $next($request); + } + $actor = $request->user(); + if (! $actor) { + return $request->expectsJson() + ? response()->json(['message' => 'Entre na conta autorizada para homologação.'], 401) + : redirect()->guest(route('login')); + } + abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)), config('portal.homologation_reviewers', []), true), 403, + 'Esta homologação está reservada às contas indicadas pela Tempo.'); + $response = $next($request); + $response->headers->set('Cache-Control', 'private, no-store'); + + return $response; + } } diff --git a/app/Http/Middleware/EnsureScopedEditorial.php b/app/Http/Middleware/EnsureScopedEditorial.php index 2dfd1e7d..2cdcb9b3 100644 --- a/app/Http/Middleware/EnsureScopedEditorial.php +++ b/app/Http/Middleware/EnsureScopedEditorial.php @@ -1,15 +1,19 @@ user(); - abort_unless($actor, 403); - $access = app(\App\Services\DocumentManagementAccess::class); + abort_unless($actor !== null, 403); + $access = app(DocumentManagementAccess::class); if ($access->full($actor)) { return $next($request); } @@ -20,6 +24,7 @@ class EnsureScopedEditorial if ($request->route('id') !== null) { $access->assertEditableResource($actor, (int) $request->route('id')); } + return $next($request); } } diff --git a/app/Http/Middleware/EnsureSimulationRecoveryCookie.php b/app/Http/Middleware/EnsureSimulationRecoveryCookie.php index 002491d5..8cf6c8c1 100644 --- a/app/Http/Middleware/EnsureSimulationRecoveryCookie.php +++ b/app/Http/Middleware/EnsureSimulationRecoveryCookie.php @@ -64,6 +64,7 @@ final class EnsureSimulationRecoveryCookie false, 'lax', )); + return $response; } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 1b35adac..b221ff27 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,10 +3,13 @@ namespace App\Http\Middleware; use App\Models\ExistingBusiness; +use App\Services\ContentManagement; use App\Services\InvestorPath; use App\Services\LegacyContent; use App\Services\OpportunityManagement; use App\Services\PortalProfileAccess; +use App\Services\Procurement; +use App\Services\ScopedPortalAdministration; use Illuminate\Http\Request; use Inertia\Middleware; @@ -48,15 +51,16 @@ class HandleInertiaRequests extends Middleware */ public function share(Request $request): array { - $contentManagementAccess=app(\App\Services\ContentManagement::class)->access($request->user()); + $contentManagementAccess = app(ContentManagement::class)->access($request->user()); + return [ ...parent::share($request), 'name' => config('app.name'), - 'procurementEnabled' => app(\App\Services\Procurement::class)->available(), + 'procurementEnabled' => app(Procurement::class)->available(), 'homologationReserved' => (bool) config('portal.homologation_only', false), 'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()), 'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null, - 'contentManagementAccess'=>$contentManagementAccess, + 'contentManagementAccess' => $contentManagementAccess, 'portalOperator' => config('portal.operator_name'), 'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null, 'legacyContentEnabled' => app(LegacyContent::class)->available(), @@ -73,14 +77,14 @@ class HandleInertiaRequests extends Middleware 'installationHref' => '/management/opportunity-management-install', ] : null, 'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')], - 'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [ - ['label'=>'Zonas e parques','href'=>'/management/investment/zones'], - ['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'], - ['label'=>'Referências e formulários','href'=>'/management/investment/references'], - ['label'=>'Educação e competências','href'=>'/management/education'], - ['label'=>'Interesses atribuídos','href'=>'/management/interests'], - ['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'], - ['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'], + 'scopedAdministration' => $request->user() && app(ScopedPortalAdministration::class)->administrative($request->user()) ? [ + ['label' => 'Zonas e parques', 'href' => '/management/investment/zones'], + ['label' => 'Referências económicas', 'href' => '/management/economic-references/lot-01'], + ['label' => 'Referências e formulários', 'href' => '/management/investment/references'], + ['label' => 'Educação e competências', 'href' => '/management/education'], + ['label' => 'Interesses atribuídos', 'href' => '/management/interests'], + ['label' => 'Atribuições de interesses', 'href' => '/management/interests/atribuicoes'], + ['label' => 'Atribuições do Balcão', 'href' => '/management/balcao/atribuicoes'], ] : [], 'auth' => [ 'user' => $request->user(), diff --git a/app/Http/Middleware/PortalProfileSession.php b/app/Http/Middleware/PortalProfileSession.php index a3be0007..3097c47f 100644 --- a/app/Http/Middleware/PortalProfileSession.php +++ b/app/Http/Middleware/PortalProfileSession.php @@ -13,15 +13,19 @@ class PortalProfileSession { public function handle(Request $request, Closure $next): Response { + if (! $request->user()) { + return $next($request); + } + $access = app(PortalProfileAccess::class); $available = $access->governanceAvailable(); - if ($request->user() && (config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) { + if ((config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) { $recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap') || ($request->is('logout') && $request->isMethod('POST')); abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503, 'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.'); } - if ($request->user() && $available) { + if ($available) { $revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision'); $bound = $request->session()->get('portal.access_revision'); if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) { diff --git a/app/Http/Middleware/VerifiedOrSimpleParticipation.php b/app/Http/Middleware/VerifiedOrSimpleParticipation.php index f4f06526..350368fa 100644 --- a/app/Http/Middleware/VerifiedOrSimpleParticipation.php +++ b/app/Http/Middleware/VerifiedOrSimpleParticipation.php @@ -13,15 +13,22 @@ class VerifiedOrSimpleParticipation { public function handle(Request $request, Closure $next): Response { - if ($request->user()->hasVerifiedEmail()) return $next($request); - if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request); + if ($request->user()->hasVerifiedEmail()) { + return $next($request); + } + if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) { + return $next($request); + } $id = $request->route('id'); $show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id); $message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages'); if ($id && ($show || $message)) { $row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail(); - if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request); + if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) { + return $next($request); + } } + return app(EnsureEmailIsVerified::class)->handle($request, $next); } } diff --git a/app/Http/Requests/StoreLandAssistanceCaseRequest.php b/app/Http/Requests/StoreLandAssistanceCaseRequest.php index 08aeda54..6b29b080 100644 --- a/app/Http/Requests/StoreLandAssistanceCaseRequest.php +++ b/app/Http/Requests/StoreLandAssistanceCaseRequest.php @@ -6,8 +6,10 @@ namespace App\Http\Requests; use App\Models\LandAssistanceCase; use App\Services\CountryCatalogue; +use App\Services\InvestmentConsumerScope; use App\Services\InvestmentGeography; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\Validator; @@ -19,9 +21,14 @@ class StoreLandAssistanceCaseRequest extends FormRequest { $payload = $this->validated('payload'); - if (!is_array($payload)) return []; - if (!empty($payload['investor_path_case_id'])) return app(\App\Services\InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support')); - return \Illuminate\Support\Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']); + if (! is_array($payload)) { + return []; + } + if (! empty($payload['investor_path_case_id'])) { + return app(InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support')); + } + + return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']); } public function authorize(): bool diff --git a/app/Http/Resources/PublicBusinessOpportunityResource.php b/app/Http/Resources/PublicBusinessOpportunityResource.php index fcc4fb52..7b5c57bd 100644 --- a/app/Http/Resources/PublicBusinessOpportunityResource.php +++ b/app/Http/Resources/PublicBusinessOpportunityResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Services\ExistingBusinessClassification; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -27,7 +28,7 @@ class PublicBusinessOpportunityResource extends JsonResource 'province' => $this->resource->getAttribute('province'), 'district' => $this->resource->getAttribute('district'), 'locality' => $this->resource->getAttribute('locality'), - 'multidimensional' => app(\App\Services\ExistingBusinessClassification::class)->publicRows($this->resource), + 'multidimensional' => app(ExistingBusinessClassification::class)->publicRows($this->resource), ]; } } diff --git a/app/Models/Contact.php b/app/Models/Contact.php index 0be8a32f..122fc6c2 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -2,4 +2,10 @@ namespace App\Models; +/** + * @property string|null $external_id + * @property string|null $source_system + * @property string $full_name + * @property string|null $phone + */ class Contact extends CrmRecord {} diff --git a/app/Models/InvestmentLegalSource.php b/app/Models/InvestmentLegalSource.php index 19f3f53c..a4643e50 100644 --- a/app/Models/InvestmentLegalSource.php +++ b/app/Models/InvestmentLegalSource.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model; * @property string $validation_status * @property string $valid_from * @property string|null $valid_until + * @property array|null $fiscal_acceptance */ class InvestmentLegalSource extends Model { diff --git a/app/Models/InvestmentZone.php b/app/Models/InvestmentZone.php index a2103ca5..45d57005 100644 --- a/app/Models/InvestmentZone.php +++ b/app/Models/InvestmentZone.php @@ -24,7 +24,7 @@ class InvestmentZone extends DomainRecord { protected $hidden = ['editorial_draft', 'operational_details']; - protected $casts = ['operational_details' => 'array', 'operational_details' => 'array', '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']; + protected $casts = ['operational_details' => 'array', '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 */ public function primaryTerritory(): BelongsTo diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php index e7c7a4bb..a8d0c2e4 100644 --- a/app/Models/Opportunity.php +++ b/app/Models/Opportunity.php @@ -2,6 +2,9 @@ namespace App\Models; +use App\Services\OpportunityMaturity; +use App\Services\OpportunityPublicText; +use App\Services\ProactiveInvestment; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -34,8 +37,13 @@ class Opportunity extends Model public function toArray(): array { $data = parent::toArray(); - if (isset($data['description'])) $data['description'] = \App\Services\OpportunityPublicText::description($this, $data['description']); - if ($this->exists && ($proactive = app(\App\Services\ProactiveInvestment::class)->publicData($this))) $data['proactive'] = $proactive; + if (isset($data['description'])) { + $data['description'] = OpportunityPublicText::description($this, $data['description']); + } + if ($this->exists && ($proactive = app(ProactiveInvestment::class)->publicData($this))) { + $data['proactive'] = $proactive; + } + return $data; } @@ -44,7 +52,7 @@ class Opportunity extends Model // Only published metadata is public; never expose the current private draft. $version = data_get($this->workflow_context, 'publication.maturity.catalogue_version'); - return in_array($version, [\App\Services\OpportunityMaturity::VERSION, \App\Services\OpportunityMaturity::LEGACY], true) + return in_array($version, [OpportunityMaturity::VERSION, OpportunityMaturity::LEGACY], true) ? $version : null; } diff --git a/app/Models/Sector.php b/app/Models/Sector.php index c6f17118..81eb7f26 100644 --- a/app/Models/Sector.php +++ b/app/Models/Sector.php @@ -2,10 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property list> $specializations + * @property string $url + */ class Sector extends Model { use SoftDeletes; @@ -20,18 +25,24 @@ class Sector extends Model 'published_at' => 'datetime', ]; - /** Presentation only: historical masters and foreign keys remain untouched. */ - public function scopeChoices($query) + /** Presentation only: historical masters and foreign keys remain untouched. + * @param Builder $query + * @return Builder + */ + public function scopeChoices(Builder $query): Builder { return $query->where('is_active', true)->where('slug', '!=', 'energia'); } - public function scopeCatalog($query) + /** @param Builder $query + * @return Builder + */ + public function scopeCatalog(Builder $query): Builder { return $query->choices()->whereNotNull('published_at')->where('slug', '!=', 'fintech'); } - public function getNameAttribute($value) + public function getNameAttribute(?string $value): ?string { return ($this->attributes['slug'] ?? null) === 'fintech' ? 'Mercado Financeiro — Fintech' : $value; } diff --git a/app/Models/SimulationTaxRuleVersion.php b/app/Models/SimulationTaxRuleVersion.php index e9ce8b02..e6b0336e 100644 --- a/app/Models/SimulationTaxRuleVersion.php +++ b/app/Models/SimulationTaxRuleVersion.php @@ -11,6 +11,9 @@ use Illuminate\Support\Carbon; * @property string $component * @property string $scope_key * @property string $status + * @property array $rule_definition + * @property array $parameters + * @property array $rounding_policy * @property int $author_id * @property int|null $reviewer_id * @property int|null $approver_id diff --git a/app/Policies/ExistingBusinessPolicy.php b/app/Policies/ExistingBusinessPolicy.php index 3377b178..67506d9d 100644 --- a/app/Policies/ExistingBusinessPolicy.php +++ b/app/Policies/ExistingBusinessPolicy.php @@ -4,6 +4,7 @@ namespace App\Policies; use App\Models\ExistingBusiness; use App\Models\User; +use App\Services\ExistingBusinessProfileCategory; use App\Services\PortalProfileAccess; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -87,11 +88,13 @@ class ExistingBusinessPolicy return $this->designatedAny($user); } - /** Filter before projecting or decrypting private fields in the review queue. */ + /** Filter before projecting or decrypting private fields in the review queue. + * @param Builder $query + */ public function scopeReviewQueue(Builder $query, User $user): void { if (app(PortalProfileAccess::class)->available()) { - $query->whereIn('modality',array_keys(\App\Services\ExistingBusinessProfileCategory::MAP)); + $query->whereIn('modality', array_keys(ExistingBusinessProfileCategory::MAP)); $ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys(); $query->whereKey($ids); } @@ -119,13 +122,18 @@ class ExistingBusinessPolicy return false; } $access = app(PortalProfileAccess::class); - if ($access->has($user, 'auditor')) { return false; } + if ($access->has($user, 'auditor')) { + return false; + } if (! $access->available()) { return $access->legacyWriteAllowed($user) && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret && in_array(strtolower($user->email), config('existing-business.reviewers', []), true); } - if ($business->modality !== 'company_sale') { return false; } + if ($business->modality !== 'company_sale') { + return false; + } $responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer'; + return collect($access->grants($user, 'validator'))->contains(fn ($g) => $g->organization_id === null && in_array('business_sale', json_decode($g->opportunity_types, true), true) && in_array($responsibility, json_decode($g->responsibilities, true), true)); @@ -137,6 +145,7 @@ class ExistingBusinessPolicy || app(PortalProfileAccess::class)->has($user, 'auditor') || ! $this->designated($user, 'publishers', $business)) { return false; } + return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id) ->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists(); } @@ -156,8 +165,8 @@ class ExistingBusinessPolicy { $access = app(PortalProfileAccess::class); if ($access->available()) { - $category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null; - if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) { + $category = $business ? ExistingBusinessProfileCategory::category($business) : null; + if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user, 'auditor')) { return false; } $role = $designation === 'publishers' ? 'publisher' : 'validator'; @@ -168,7 +177,7 @@ class ExistingBusinessPolicy $organizationMatches = $grant->organization_id === null || ($business !== null && $grant->organization_id === $business->organization_id); if ($organizationMatches - && ($category !== null ? in_array($category,json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR),true) : array_intersect(array_values(\App\Services\ExistingBusinessProfileCategory::MAP),json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR)) !== [])) { + && ($category !== null ? in_array($category, json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true) : array_intersect(array_values(ExistingBusinessProfileCategory::MAP), json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR)) !== [])) { return true; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9eca6389..049084b5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,6 +22,8 @@ use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Inertia\Inertia; +use Laravel\Fortify\Contracts\LogoutResponse; use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided; class AppServiceProvider extends ServiceProvider @@ -45,12 +47,12 @@ class AppServiceProvider extends ServiceProvider // Fortify calls this response AFTER invalidating/regenerating the server session. // Clear the client's encryption key too, so Back must reauthorize private pages. - $this->app->bind(\Laravel\Fortify\Contracts\LogoutResponse::class, function () { + $this->app->bind(LogoutResponse::class, function () { return new class extends \Laravel\Fortify\Http\Responses\LogoutResponse { public function toResponse($request) { - \Inertia\Inertia::clearHistory(); + Inertia::clearHistory(); return parent::toResponse($request); } @@ -77,6 +79,8 @@ class AppServiceProvider extends ServiceProvider Cache::forget('portal_home_en'); Cache::forget('portal_home_v2_pt'); Cache::forget('portal_home_v2_en'); + Cache::forget('portal_home_sector_organization_v1_pt'); + Cache::forget('portal_home_sector_organization_v1_en'); }; $model::saved($invalidate); $model::deleted($invalidate); diff --git a/app/Services/Ai/InvestorGuidance.php b/app/Services/Ai/InvestorGuidance.php index 40534f82..eea74a1c 100644 --- a/app/Services/Ai/InvestorGuidance.php +++ b/app/Services/Ai/InvestorGuidance.php @@ -24,8 +24,8 @@ class InvestorGuidance return Schema::hasColumn('tasks', 'desk_case_id'); } - /** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator. */ - /** @return array */ + /** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator. + * @return array */ public function context(User $actor, \stdClass $case): array { $base = $this->desk->decode($case->payload); diff --git a/app/Services/BusinessFormSchema.php b/app/Services/BusinessFormSchema.php index 542b1d6a..f4bb7676 100644 --- a/app/Services/BusinessFormSchema.php +++ b/app/Services/BusinessFormSchema.php @@ -103,7 +103,9 @@ final class BusinessFormSchema // The field limit is a text length, not an 8% ceiling. $rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100', function ($attribute, $value, $fail) { - if (strlen((string) $value) > 8) $fail('Indique a percentagem com até 8 caracteres.'); + if (strlen((string) $value) > 8) { + $fail('Indique a percentagem com até 8 caracteres.'); + } }]; foreach (['valid_until', 'mandate_valid_until'] as $date) { $rules['payload.'.$date][] = 'date_format:Y-m-d'; diff --git a/app/Services/ClusterCompetitiveness.php b/app/Services/ClusterCompetitiveness.php index 44ac5916..a1d11353 100644 --- a/app/Services/ClusterCompetitiveness.php +++ b/app/Services/ClusterCompetitiveness.php @@ -23,17 +23,26 @@ final class ClusterCompetitiveness ->where('entity_type', 'economic_clusters')->where('entity_id', $cluster); } - private function decode(?object $event): array + /** + * @return array + */ + private function decode(?\stdClass $event): array { return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR) : ['version' => 0, 'draft' => null, 'approved' => null]; } + /** + * @return array + */ public function current(string $cluster): array { return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first()); } + /** + * @return array + */ public function management(string $cluster): array { $state = $this->current($cluster); @@ -44,6 +53,10 @@ final class ClusterCompetitiveness return $state; } + /** + * @param array $draft + * @return array + */ public function validateDraft(array $draft, string $cluster): array { $rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster, @@ -69,6 +82,9 @@ final class ClusterCompetitiveness return $clean; } + /** + * @param array|null $draft + */ private function assertComplete(?array $draft): void { $missing = ! $draft || ! $draft['sector'] || ! $draft['region']; @@ -84,7 +100,10 @@ final class ClusterCompetitiveness } } - /** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. */ + /** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. + * @param array $input + * @return array + */ public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array { return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) { @@ -96,7 +115,7 @@ final class ClusterCompetitiveness $state['draft'] = $this->validateDraft($input['draft'], $cluster->id); } elseif ($action === 'restored') { $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); - abort_unless($event, 404); + abort_unless($event !== null, 404); $state['draft'] = $this->decode($event)['draft']; } elseif ($action === 'approved') { $this->assertComplete($state['draft']); @@ -120,6 +139,9 @@ final class ClusterCompetitiveness }); } + /** + * @return array|null + */ public function publicData(string $cluster): ?array { $approved = $this->current($cluster)['approved']; diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php index 7d3df9b2..44181153 100644 --- a/app/Services/ContentManagement.php +++ b/app/Services/ContentManagement.php @@ -9,9 +9,14 @@ use Illuminate\Support\Facades\DB; /** Navigation over existing authorisations; never grants a role or publishes content. */ final class ContentManagement { + /** + * @return array + */ public function access(?User $actor): array { - if (! $actor || ! $actor->hasVerifiedEmail()) return []; + if (! $actor || ! $actor->hasVerifiedEmail()) { + return []; + } $profiles = app(PortalProfileAccess::class); $navigation = $profiles->navigation($actor); $legacy = $profiles->legacyWriteAllowed($actor); @@ -19,15 +24,23 @@ final class ContentManagement $editor = $documents->full($actor) || $documents->editorial($actor); $upload = $editor || $documents->limited($actor); $result = []; - if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo']; + if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) { + $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo']; + } if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) { $result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades', 'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy]; } - if ($upload) $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor]; + if ($upload) { + $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor]; + } + return $result; } + /** + * @return array + */ public function view(User $actor): array { $access = $this->access($actor); @@ -45,6 +58,7 @@ final class ContentManagement $counts['documents'] = $query ? $query->select('r.published', DB::raw('count(*) as total'))->groupBy('r.published')->get() ->mapWithKeys(fn ($row) => [$row->published ? 'published' : 'unpublished' => (int) $row->total]) : []; } + return ['access' => $access, 'counts' => $counts]; } } diff --git a/app/Services/CorridorAgribusiness.php b/app/Services/CorridorAgribusiness.php index cf3df43b..b52c8c07 100644 --- a/app/Services/CorridorAgribusiness.php +++ b/app/Services/CorridorAgribusiness.php @@ -2,15 +2,19 @@ namespace App\Services; +use App\Support\CheckedFile; use DOMDocument; use DOMElement; /** Supplement the six named agribusiness sections without changing corridor masters or source documents. */ final class CorridorAgribusiness { + /** + * @param array $links + */ public function enrich(string $html, array $links = []): string { - $data = json_decode(file_get_contents(__DIR__.'/../../database/data/corridor-investment-opportunities.json'), true, 512, JSON_THROW_ON_ERROR); + $data = json_decode(CheckedFile::read(__DIR__.'/../../database/data/corridor-investment-opportunities.json'), true, 512, JSON_THROW_ON_ERROR); $doc = new DOMDocument; $previous = libxml_use_internal_errors(true); try { diff --git a/app/Services/CorridorAgribusinessOpportunities.php b/app/Services/CorridorAgribusinessOpportunities.php index bc9d90d5..144ce5e6 100644 --- a/app/Services/CorridorAgribusinessOpportunities.php +++ b/app/Services/CorridorAgribusinessOpportunities.php @@ -2,9 +2,11 @@ namespace App\Services; +use App\Models\InvestmentDossier; use App\Models\Opportunity; use App\Models\Sector; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Ramsey\Uuid\Uuid; @@ -14,22 +16,33 @@ final class CorridorAgribusinessOpportunities { public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055'; - /** Documentary cross-reference only; keep the two source designations and master records intact. */ - public function portfolioOpportunityIds(\App\Models\InvestmentDossier $dossier): array + /** Documentary cross-reference only; keep the two source designations and master records intact. + * @return array + */ + public function portfolioOpportunityIds(InvestmentDossier $dossier): array { - if (!$dossier->corridor_id || data_get($dossier->content, 'code') !== 'COR-02') return []; + if (! $dossier->corridor_id || data_get($dossier->content, 'code') !== 'COR-02') { + return []; + } // COR-02 is Beira e Centro in investment-portfolio.json. The seven entries are explicitly // grouped under Corredor da Beira in the accepted source, not selected by province/name search. $keys = collect($this->entries())->where('corridor', 'Corredor da Beira')->pluck('key'); + return Opportunity::published()->whereIn('management_creation_key', $keys) ->where('workflow_context->corridor_agribusiness->source_sha256', self::SOURCE_HASH)->pluck('id')->all(); } + public const LIMITATIONS = 'Promotor, implantação exacta, área, montante, financiamento, viabilidade e licenças não indicados nesta fonte. O corredor é um enquadramento documental; não identifica uma parcela nem confirma disponibilidade.'; + /** + * @return array + */ public function entries(): array { - $data = json_decode(file_get_contents(database_path('data/corridor-investment-opportunities.json')), true, 512, JSON_THROW_ON_ERROR); - if ($data['source_sha256'] !== self::SOURCE_HASH) throw new \RuntimeException('Fonte divergente; reconciliar antes de cadastrar.'); + $data = json_decode(CheckedFile::read(database_path('data/corridor-investment-opportunities.json')), true, 512, JSON_THROW_ON_ERROR); + if ($data['source_sha256'] !== self::SOURCE_HASH) { + throw new \RuntimeException('Fonte divergente; reconciliar antes de cadastrar.'); + } $sector = Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNull('deleted_at')->firstOrFail(); $entries = []; foreach ($data['groups'] as $g => $group) { @@ -42,14 +55,21 @@ final class CorridorAgribusinessOpportunities 'summary' => $item['description'], 'description' => $description, 'sector_id' => $sector->id]; } } - if (count($entries) !== 34) throw new \RuntimeException('O lote deve conter as 34 entradas da fonte.'); + if (count($entries) !== 34) { + throw new \RuntimeException('O lote deve conter as 34 entradas da fonte.'); + } + return $entries; } + /** + * @return array + */ public function register(User $actor, string $organization): array { // The caller uses the ordinary profile creation route and its approved scope. abort_unless(str_starts_with(request()->path(), 'minhas-oportunidades'), 422); + return DB::transaction(function () use ($actor, $organization) { DB::select('SELECT pg_advisory_xact_lock(231026, 34)'); $result = []; @@ -60,6 +80,7 @@ final class CorridorAgribusinessOpportunities abort_unless(data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') === self::SOURCE_HASH, 409); // Recovery never overwrites editorial changes, withdrawal or archive. $result[] = ['id' => $record->id, 'created' => false, 'key' => $entry['key']]; + continue; } $record = app(OpportunityManagement::class)->save($actor, ['creation_key' => $entry['key'], 'organization_id' => $organization, @@ -81,17 +102,29 @@ final class CorridorAgribusinessOpportunities 'after_data' => json_encode(['record_id' => $record->id, 'source' => $entry, 'no_publication' => true, 'no_master_relation_inferred' => true], JSON_THROW_ON_ERROR)]); $result[] = ['id' => $record->id, 'created' => true, 'key' => $entry['key']]; } + return $result; }); } + /** + * @return array + */ public function articleLinks(): array { + // Reading an original article must work before the optional sector/opportunity catalogue is loaded. + if (! Sector::where('slug', 'agronegocio')->where('is_active', true)->exists()) { + return []; + } + $records = Opportunity::published()->whereIn('management_creation_key', array_column($this->entries(), 'key'))->get()->keyBy('management_creation_key'); $links = []; foreach ($this->entries() as $entry) { - if ($record = $records->get($entry['key'])) $links[$entry['group']][$entry['index']] = '/oportunidades/'.$record->slug; + if ($record = $records->get($entry['key'])) { + $links[$entry['group']][$entry['index']] = '/oportunidades/'.$record->slug; + } } + return $links; } } diff --git a/app/Services/DeclaredOriginStatistics.php b/app/Services/DeclaredOriginStatistics.php index 0afa9b69..68c08059 100644 --- a/app/Services/DeclaredOriginStatistics.php +++ b/app/Services/DeclaredOriginStatistics.php @@ -15,8 +15,8 @@ final class DeclaredOriginStatistics abort_unless(app(ScopedPortalAdministration::class)->interestOperator($actor), 403); $names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code'); $groups = []; - $interests=DB::table('interest_requests')->whereNull('deleted_at'); - app(ScopedPortalAdministration::class)->interestQuery($interests,$actor); + $interests = DB::table('interest_requests')->whereNull('deleted_at'); + app(ScopedPortalAdministration::class)->interestQuery($interests, $actor); foreach ($interests->select('metadata')->cursor() as $row) { $metadata = json_decode($row->metadata ?? '{}', true); $code = $metadata['declared_origin_country_code'] ?? null; diff --git a/app/Services/DocumentManagementAccess.php b/app/Services/DocumentManagementAccess.php index 735eaed2..488e21e8 100644 --- a/app/Services/DocumentManagementAccess.php +++ b/app/Services/DocumentManagementAccess.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -37,6 +38,7 @@ class DocumentManagementAccess public function editorialResources(User $actor): Builder { abort_unless($this->editorial($actor), 403); + return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') ->where(function ($query) use ($actor) { @@ -50,7 +52,7 @@ class DocumentManagementAccess ->where('d.external_id', $item['external_id']); }); } - foreach (json_decode(file_get_contents(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $item) { + foreach (json_decode(CheckedFile::read(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $item) { // These exact original bytes contain the legacy identity/source. // Destination upload IDs differ; the approved file scope does not. $query->orWhere('d.sha256', $item['sha256']); @@ -80,6 +82,9 @@ class DocumentManagementAccess return $query; } + /** + * @param array $data + */ public function audit(User $actor, string $action, array $data): void { DB::table('audit_events')->insert([ diff --git a/app/Services/EconomicReferenceCatalog.php b/app/Services/EconomicReferenceCatalog.php index bf219c70..d27dc964 100644 --- a/app/Services/EconomicReferenceCatalog.php +++ b/app/Services/EconomicReferenceCatalog.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Support\CheckedFile; use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -11,14 +12,19 @@ use Illuminate\Support\Facades\Schema; class EconomicReferenceCatalog { public const LOT = 'lot-01'; + public const SOURCE = 'economic-reference-lot-01'; + public const MANIFEST_SHA256 = 'ba1bb3406fd55e39c19fd2a2755f3dfc2a1160b67eee8b3b6e12cd43f4223b04'; + /** + * @return array + */ public function manifest(): array { $file = database_path('data/economic-references-lot-01.json'); - abort_unless(is_file($file) && hash_equals(self::MANIFEST_SHA256, hash_file('sha256', $file)), 422, 'Manifesto económico ausente ou diferente da revisão delimitada.'); - $manifest = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + abort_unless(is_file($file) && hash_equals(self::MANIFEST_SHA256, CheckedFile::sha256($file)), 422, 'Manifesto económico ausente ou diferente da revisão delimitada.'); + $manifest = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR); abort_unless(($manifest['lot'] ?? '') === self::LOT && count($manifest['activities'] ?? []) === 5 && count($manifest['chains'] ?? []) === 5 && count($manifest['links'] ?? []) === 4, 422, 'Lote económico fora do âmbito.'); @@ -40,7 +46,9 @@ class EconomicReferenceCatalog public function available(): bool { - if (! $this->schemaAvailable() || $this->publicationState() !== 'published') { return false; } + if (! $this->schemaAvailable() || $this->publicationState() !== 'published') { + return false; + } return DB::table('reference_catalog_versions')->where('id', $this->manifest()['catalog']['id'])->where('status', 'published')->exists(); } @@ -48,7 +56,9 @@ class EconomicReferenceCatalog public function constrain(Builder $query, string $kind): Builder { $manifest = $this->manifest(); - if ($kind === 'activity') $query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID); + if ($kind === 'activity') { + $query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID); + } if (in_array($kind, ['activity', 'chain'], true) && ! $this->available()) { $query->whereNotIn('id', array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id')); } @@ -59,17 +69,29 @@ class EconomicReferenceCatalog /** A withdrawn published reference can be retained verbatim, never introduced as a new choice. */ public function historicalTarget(string $kind, string $id): bool { - if ($kind === 'activity' && $id === SectorOrganization::LEGACY_ACTIVITY_ID) return DB::table('economic_activities')->where('id', $id)->where('sector_id', 2)->where('status', 'published')->exists(); - if (! in_array($kind, ['activity', 'chain'], true) || ! $this->schemaAvailable()) { return false; } + if ($kind === 'activity' && $id === SectorOrganization::LEGACY_ACTIVITY_ID) { + return DB::table('economic_activities')->where('id', $id)->where('sector_id', 2)->where('status', 'published')->exists(); + } + if (! in_array($kind, ['activity', 'chain'], true) || ! $this->schemaAvailable()) { + return false; + } $manifest = $this->manifest(); - if (! in_array($id, array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'), true)) { return false; } - if (! DB::table('audit_events')->where('source_system', self::SOURCE)->where('action', 'publish')->exists()) { return false; } + if (! in_array($id, array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'), true)) { + return false; + } + if (! DB::table('audit_events')->where('source_system', self::SOURCE)->where('action', 'publish')->exists()) { + return false; + } return $kind === 'activity' ? DB::table('economic_activities')->where('id', $id)->where('status', 'published')->exists() : DB::table('value_chains')->where('id', $id)->whereNull('deleted_at')->exists(); } + /** + * @param array $historicalIds + * @return Collection + */ public function activities(array $historicalIds = []): Collection { $rows = DB::table('economic_activities')->where('status', 'published')->get(); @@ -79,14 +101,19 @@ class EconomicReferenceCatalog foreach ($historicalIds as $id) { $seen = []; while (isset($byId[$id]) && ! isset($seen[$id])) { - $allowed[] = $id; $seen[$id] = true; $id = $byId[$id]->parent_id; + $allowed[] = $id; + $seen[$id] = true; + $id = $byId[$id]->parent_id; } } $available = $this->available(); - return $rows->filter(fn ($r) => in_array($r->id, $allowed, true))->map(function ($row) use ($byId, $available) { + + return $rows->filter(fn ($r) => in_array($r->id, $allowed, true))->map(function ($row) use ($byId, $available): \stdClass { $meta = property_exists($row, 'metadata') ? json_decode($row->metadata, true) : []; - $path = []; $current = $row; $seen = []; + $path = []; + $current = $row; + $seen = []; while ($current && ! isset($seen[$current->id])) { $seen[$current->id] = true; array_unshift($path, ['id' => $current->id, 'code' => $current->code, 'label' => $current->label_pt_mz]); @@ -94,21 +121,31 @@ class EconomicReferenceCatalog } $isLot = ($meta['lot'] ?? '') === self::LOT; - return (object) ['id' => $row->id, 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id, + $option = ['id' => $row->id, 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id, 'label_pt_mz' => $row->label_pt_mz, 'catalog_version_id' => $row->catalog_version_id, 'code' => $row->code, 'level' => $meta['level'] ?? null, 'path' => $path, 'display_label' => implode(' → ', array_column($path, 'code')).' — '.$row->label_pt_mz, 'source_version' => $meta['source_version'] ?? null, 'edition' => $meta['edition'] ?? null, 'historical_only' => $isLot && ! $available]; + $result = new \stdClass; + foreach ($option as $key => $value) { + $result->{$key} = $value; + } + + return $result; })->values(); } public function validHierarchy(int $sectorId, ?string $subsectorId, ?string $activityId, bool $historical = false): bool { - if (! $subsectorId || ! $activityId) { return false; } + if (! $subsectorId || ! $activityId) { + return false; + } $sector = DB::table('sectors')->where('id', $sectorId)->whereNull('deleted_at')->where('is_active', true)->exists(); $subsector = DB::table('economic_activities')->where('id', $subsectorId)->where('status', 'published')->first(); $activity = DB::table('economic_activities')->where('id', $activityId)->where('status', 'published')->first(); - if (! $sector || ! $subsector || ! $activity || $activity->catalog_version_id !== $subsector->catalog_version_id) { return false; } + if (! $sector || ! $subsector || ! $activity || $activity->catalog_version_id !== $subsector->catalog_version_id) { + return false; + } $manifest = $this->manifest(); if ($activity->catalog_version_id !== $manifest['catalog']['id']) { $root = $subsector->parent_id ? DB::table('economic_activities')->where('id', $subsector->parent_id)->where('status', 'published')->first() : null; @@ -116,10 +153,15 @@ class EconomicReferenceCatalog return $root && (int) $root->sector_id === $sectorId && $activity->parent_id === $subsector->id; } if ((! $this->available() && ! $historical) || $sectorId !== (int) $manifest['existing']['sector']['id'] - || $subsector->code !== '01' || $activity->code !== '01120') { return false; } - $current = $activity; $codes = []; $seen = []; + || $subsector->code !== '01' || $activity->code !== '01120') { + return false; + } + $current = $activity; + $codes = []; + $seen = []; while ($current && ! isset($seen[$current->id])) { - $seen[$current->id] = true; array_unshift($codes, $current->code); + $seen[$current->id] = true; + array_unshift($codes, $current->code); $current = $current->parent_id ? DB::table('economic_activities')->where('id', $current->parent_id)->where('catalog_version_id', $activity->catalog_version_id)->where('status', 'published')->first() : null; } diff --git a/app/Services/EconomicReferenceInstallation.php b/app/Services/EconomicReferenceInstallation.php index 929e8c80..ded81c73 100644 --- a/app/Services/EconomicReferenceInstallation.php +++ b/app/Services/EconomicReferenceInstallation.php @@ -4,19 +4,22 @@ namespace App\Services; use App\Models\User; use App\Services\Malware\MalwareScanner; +use App\Support\CheckedFile; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; -use Illuminate\Support\Str; use Symfony\Component\HttpKernel\Exception\HttpException; /** Bounded authenticated extension. No generic seeding, arbitrary path or caller-supplied actor. */ class EconomicReferenceInstallation { public const MIGRATION = '2026_10_16_120000_extend_economic_reference_hierarchy'; + public const OPERATIONS = ['schema', 'import', 'publish', 'withdraw']; + /** + * @return array + */ public function files(): array { return ['app/Services/EconomicReferenceCatalog.php', 'app/Services/EconomicReferenceInstallation.php', @@ -32,13 +35,22 @@ class EconomicReferenceInstallation return hash('sha256', json_encode(array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()), JSON_THROW_ON_ERROR)); } - private function directory(): string { return storage_path('app/private/investmoz-v2/economic-references/lot-01'); } + private function directory(): string + { + return storage_path('app/private/investmoz-v2/economic-references/lot-01'); + } + /** + * @return array + */ private function target(): array { return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); } + /** + * @param array $target + */ private function qa(array $target): bool { return app()->environment('testing') && str_ends_with($target['database'], '_test') && (int) $target['port'] === 55441 @@ -48,20 +60,32 @@ class EconomicReferenceInstallation private function authorize(User $actor, bool $write = false, string $operation = ''): void { abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Acesso reservado à gestão autorizada.'); - if (! $write) { return; } + if (! $write) { + return; + } $access = app(PortalProfileAccess::class); abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.'); abort_unless($access->mfa($actor), 403, 'Conclua MFA nesta sessão para executar esta ação.'); - if ($operation === 'schema') { app(PortalProfileInstallation::class)->authorize($actor, true); } + if ($operation === 'schema') { + app(PortalProfileInstallation::class)->authorize($actor, true); + } abort_unless(! app()->environment('testing') || $this->qa($this->target()), 403, 'Alvo QA isolado inválido.'); } + /** + * @param array $target + * @return array + */ private function evidence(array $target, string $code, string $operation): array { - if ($this->qa($target)) { return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null]; } - if (app()->environment('testing')) { return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null]; } + if ($this->qa($target)) { + return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null]; + } + if (app()->environment('testing')) { + return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null]; + } $path = $this->directory().'/readiness.json'; - $proof = is_file($path) ? json_decode(file_get_contents($path), true) : []; + $proof = is_file($path) ? json_decode(CheckedFile::read($path), true) : []; $date = strtotime($proof['verified_at'] ?? ''); $permission = $proof['active_authorization'] ?? []; $ready = ! config('database.connections.pgsql.url') && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true) @@ -73,8 +97,8 @@ class EconomicReferenceInstallation && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false) && str_ends_with($proof['restore_target']['database'] ?? '', '_test') && (int) ($proof['restore_target']['port'] ?? 0) === 55441 && is_file($proof['dump_path'] ?? '') && is_file($proof['files_path'] ?? '') - && hash_equals($proof['dump_sha256'] ?? '', hash_file('sha256', $proof['dump_path'])) - && hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path'])) + && hash_equals($proof['dump_sha256'] ?? '', CheckedFile::sha256($proof['dump_path'])) + && hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path'])) && ($permission['decision_record'] ?? '') !== '' && ($permission['code_hash'] ?? '') === $code && ($permission['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256 && in_array($operation, $permission['operations'] ?? [], true); @@ -83,25 +107,35 @@ class EconomicReferenceInstallation 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null]; } - /** No source scan/write in preview. Paths are fixed, private and confined to this lot. */ + /** No source scan/write in preview. Paths are fixed, private and confined to this lot. + * @param array $manifest + * @return array + */ private function sources(array $manifest): array { - $root = realpath($this->directory()); $sources = []; + $root = realpath($this->directory()); + $sources = []; foreach ($manifest['sources'] as $entry) { $path = $root ? realpath($root.DIRECTORY_SEPARATOR.$entry['relative_path']) : false; $inside = $path && str_starts_with(strtolower(str_replace('\\', '/', $path)), strtolower(str_replace('\\', '/', $root)).'/'); - $matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], hash_file('sha256', $path)); + $matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], CheckedFile::sha256($path)); $sources[] = ['name' => $entry['name'], 'sha256' => $entry['sha256'], 'bytes' => $entry['bytes'], 'ready' => (bool) $matches]; } return $sources; } + /** + * @param array $manifest + * @return array + */ private function referenceRows(array $manifest): array { $rows = [['table' => 'reference_catalog_versions', 'data' => $manifest['catalog']]]; foreach (['activities' => 'economic_activities', 'chains' => 'value_chains', 'links' => 'value_chain_links'] as $group => $table) { - foreach ($manifest[$group] as $row) { $rows[] = ['table' => $table, 'data' => $row]; } + foreach ($manifest[$group] as $row) { + $rows[] = ['table' => $table, 'data' => $row]; + } } return $rows; @@ -113,41 +147,67 @@ class EconomicReferenceInstallation ->whereRaw("after_data->'result'->'changed' @> ?::jsonb", [json_encode([$id])])->exists(); } + /** + * @param array $manifest + * @return array + */ private function references(array $manifest, bool $schemaReady): array { $results = []; foreach ($this->referenceRows($manifest) as $row) { - $expected = $row['data']; $table = $row['table']; + $expected = $row['data']; + $table = $row['table']; $current = DB::table($table)->where('id', $expected['id'])->first(); $conflict = false; foreach ($expected as $key => $value) { - if (! $current || ($key === 'metadata' && ! $schemaReady)) { continue; } - if ($key === 'status') { $conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true); continue; } - if ($key === 'data_classification') { $conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true); continue; } + if (! $current || ($key === 'metadata' && ! $schemaReady)) { + continue; + } + if ($key === 'status') { + $conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true); + + continue; + } + if ($key === 'data_classification') { + $conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true); + + continue; + } $actual = $key === 'metadata' ? json_decode($current->$key, true) : $current->$key; - if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) { $conflict = true; } + if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) { + $conflict = true; + } } $identity = DB::table($table)->where('id', '!=', $expected['id']); - if ($table === 'reference_catalog_versions') { $identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']); } - elseif ($table === 'economic_activities') { $identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']); } - else { $identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']); } + if ($table === 'reference_catalog_versions') { + $identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']); + } elseif ($table === 'economic_activities') { + $identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']); + } else { + $identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']); + } $conflict = $conflict || $identity->exists(); if ($table === 'value_chains') { $conflict = $conflict || DB::table($table)->where('id', '!=', $expected['id'])->where('cluster_id', $expected['cluster_id'])->where('code', $expected['code'])->whereNull('deleted_at')->exists(); } $results[] = ['table' => $table, 'id' => $expected['id'], 'code' => $expected['code'] ?? $expected['external_id'] ?? $expected['catalog_code'], 'status' => $conflict ? 'conflict' : ($current ? 'reuse' : 'insert'), 'current_hash' => $current ? OpportunityCanonicalValue::hash((array) $current) : null, - 'current_visibility' => $current?->status ?? $current?->data_classification ?? null]; + 'current_visibility' => $current->status ?? $current->data_classification ?? null]; } return $results; } + /** + * @return array + */ public function preview(User $actor): array { $this->authorize($actor); - $catalog = app(EconomicReferenceCatalog::class); $manifest = $catalog->manifest(); - $target = $this->target(); $code = $this->fingerprint(); + $catalog = app(EconomicReferenceCatalog::class); + $manifest = $catalog->manifest(); + $target = $this->target(); + $code = $this->fingerprint(); $ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists(); $column = $catalog->schemaAvailable(); $unique = DB::selectOne("SELECT 1 FROM pg_constraint WHERE conrelid='public.economic_activities'::regclass AND conname='economic_activities_sector_uq'") !== null; @@ -155,19 +215,31 @@ class EconomicReferenceInstallation $index = DB::selectOne("SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='economic_activities_sector_idx'") !== null; $installed = $ledger && $column && ! $unique && $sectorNullable && $index; $consistent = $installed || (! $ledger && ! $column && $unique && ! $sectorNullable && ! $index); - $rows = $this->references($manifest, $column); $sources = $this->sources($manifest); $reasons = []; + $rows = $this->references($manifest, $column); + $sources = $this->sources($manifest); + $reasons = []; foreach (['sector' => 'sectors', 'cluster' => 'economic_clusters'] as $name => $table) { - $expected = $manifest['existing'][$name]; $record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first(); + $expected = $manifest['existing'][$name]; + $record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first(); foreach (array_filter($expected, fn ($v) => $v !== null) as $field => $value) { - if (! $record || (string) ($record->$field ?? '') !== (string) $value) { $reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.'; break; } + if (! $record || (string) ($record->$field ?? '') !== (string) $value) { + $reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.'; + break; + } } } - if (! $consistent) { $reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.'; } - if (collect($rows)->contains('status', 'conflict')) { $reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.'; } - if (collect($sources)->contains('ready', false)) { $reasons[] = 'Exemplar privado ausente ou com bytes diferentes.'; } + if (! $consistent) { + $reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.'; + } + if (collect($rows)->contains('status', 'conflict')) { + $reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.'; + } + if (collect($sources)->contains('ready', false)) { + $reasons[] = 'Exemplar privado ausente ou com bytes diferentes.'; + } $imported = ! collect($rows)->contains(fn ($r) => $r['status'] !== 'reuse'); $scanPath = $this->directory().'/scans.json'; - $scan = is_file($scanPath) ? json_decode(file_get_contents($scanPath), true) : null; + $scan = is_file($scanPath) ? json_decode(CheckedFile::read($scanPath), true) : null; $state = ['target' => $target, 'code_hash' => $code, 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'imported' => $imported, 'publication' => $catalog->publicationState(), 'rows' => $rows, 'sources' => $sources, 'scan' => $scan, @@ -175,15 +247,27 @@ class EconomicReferenceInstallation $operations = []; foreach (self::OPERATIONS as $operation) { $why = $reasons; - try { $this->authorize($actor, true, $operation); } catch (HttpException $e) { $why[] = $e->getMessage(); } + try { + $this->authorize($actor, true, $operation); + } catch (HttpException $e) { + $why[] = $e->getMessage(); + } $evidence = $this->evidence($target, $code, $operation); - if (! $evidence['ready']) { $why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.'; } - if ($operation !== 'schema' && ! $installed) { $why[] = 'Instale primeiro o esquema nominal.'; } - if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) { $why[] = 'Importação privada completa necessária antes de publicar/retirar.'; } + if (! $evidence['ready']) { + $why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.'; + } + if ($operation !== 'schema' && ! $installed) { + $why[] = 'Instale primeiro o esquema nominal.'; + } + if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) { + $why[] = 'Importação privada completa necessária antes de publicar/retirar.'; + } if (in_array($operation, ['publish', 'withdraw'], true) && ! DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('action', 'import')->where('after_data->result->manifest_sha256', EconomicReferenceCatalog::MANIFEST_SHA256)->exists()) { $why[] = 'Execute a importação privada autenticada, inclusive para registos reutilizados.'; } - if ($operation === 'withdraw' && $state['publication'] === 'private') { $why[] = 'O lote ainda não teve publicação para retirar.'; } + if ($operation === 'withdraw' && $state['publication'] === 'private') { + $why[] = 'O lote ainda não teve publicação para retirar.'; + } if ($operation === 'withdraw' && collect($rows)->contains(fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id']))) { $why[] = 'Há referências anteriores já públicas reutilizadas: reconciliar o mapa de retirada antes de agir.'; @@ -195,22 +279,34 @@ class EconomicReferenceInstallation return [...$state, 'operations' => $operations]; } + /** + * @param array $manifest + * @return array + */ private function scan(array $manifest): array { - $directory = realpath($this->directory()); $records = []; - abort_unless($directory, 422, 'Área privada do lote indisponível.'); + $directory = realpath($this->directory()); + $records = []; + abort_unless(is_string($directory), 422, 'Área privada do lote indisponível.'); $paths = [['name' => 'manifesto', 'path' => database_path('data/economic-references-lot-01.json'), 'sha256' => EconomicReferenceCatalog::MANIFEST_SHA256]]; - foreach ($manifest['sources'] as $source) { $paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']]; } + foreach ($manifest['sources'] as $source) { + $paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']]; + } foreach ($paths as $file) { - abort_unless(is_file($file['path']) && hash_equals($file['sha256'], hash_file('sha256', $file['path'])), 409, 'Originais alterados após prévia.'); + abort_unless(is_file($file['path']) && hash_equals($file['sha256'], CheckedFile::sha256($file['path'])), 409, 'Originais alterados após prévia.'); // Scanner's path confinement uses the private source directory; manifest is scanned via a private exact copy. $path = $file['path']; - if ($file['name'] === 'manifesto') { $path = $directory.'/manifest-scanned.json'; file_put_contents($path, file_get_contents($file['path'])); } - $result = app(MalwareScanner::class)->scan($path, filesize($path), $directory); - if ($result['signatures_at'] instanceof \DateTimeInterface) { $result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM); } + if ($file['name'] === 'manifesto') { + $path = $directory.'/manifest-scanned.json'; + file_put_contents($path, file_get_contents($file['path'])); + } + $result = app(MalwareScanner::class)->scan($path, CheckedFile::size($path), $directory); + if ($result['signatures_at'] instanceof \DateTimeInterface) { + $result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM); + } $records[] = ['name' => $file['name'], 'sha256' => $file['sha256'], 'scan' => $result]; abort_unless($result['result'] === 'clean', 423, 'Scan não concluído ou não limpo para '.$file['name'].'. Nenhuma referência foi importada/publicada.'); - abort_unless(hash_equals($file['sha256'], hash_file('sha256', $file['path'])) && hash_equals($file['sha256'], hash_file('sha256', $path)), 409, 'Bytes alterados durante o scan.'); + abort_unless(CheckedFile::matches($file['path'], $file['sha256']) && CheckedFile::matches($path, $file['sha256']), 409, 'Bytes alterados durante o scan.'); } $proof = ['at' => now()->toIso8601String(), 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'files' => $records]; file_put_contents($directory.'/scans.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); @@ -218,11 +314,15 @@ class EconomicReferenceInstallation return $proof; } + /** + * @param array $input + * @return array + */ public function apply(User $actor, string $operation, array $input): array { abort_unless(in_array($operation, self::OPERATIONS, true), 404); $this->authorize($actor, true, $operation); - abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']), 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.'); + abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']) !== [], 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.'); $data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate(); return DB::transaction(function () use ($actor, $operation, $data) { @@ -235,44 +335,73 @@ class EconomicReferenceInstallation return [...$receipt['result'], 'replayed' => true, 'current_publication' => app(EconomicReferenceCatalog::class)->publicationState()]; } - $plan = $this->preview($actor); $phase = $plan['operations'][$operation]; + $plan = $this->preview($actor); + $phase = $plan['operations'][$operation]; abort_unless(hash_equals($phase['hash'], $data['hash']), 409, 'Alvo, ator, revisão ou estado alterados; consulte nova prévia.'); abort_unless($phase['ready'], 422, implode(' ', $phase['reasons'])); - $manifest = app(EconomicReferenceCatalog::class)->manifest(); $changed = []; $reused = []; - foreach ($this->referenceRows($manifest) as $row) { DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first(); } + $manifest = app(EconomicReferenceCatalog::class)->manifest(); + $changed = []; + $reused = []; + foreach ($this->referenceRows($manifest) as $row) { + DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first(); + } DB::table('sectors')->where('id', $manifest['existing']['sector']['id'])->lockForUpdate()->first(); DB::table('economic_clusters')->where('id', $manifest['existing']['cluster']['id'])->lockForUpdate()->first(); // Recheck after locks in case another approved writer changed a reused master. abort_unless($this->preview($actor)['operations'][$operation]['hash'] === $phase['hash'], 409, 'Referências alteradas durante a preparação da operação.'); if ($operation === 'schema' && ! $plan['installed']) { config(['economic_references.authenticated_installation' => true]); - try { abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.'); } - finally { config(['economic_references.authenticated_installation' => false]); } + try { + abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.'); + } finally { + config(['economic_references.authenticated_installation' => false]); + } abort_unless($this->preview($actor)['installed'], 500, 'Esquema instalado não corresponde ao plano.'); $changed[] = self::MIGRATION; } elseif ($operation === 'import') { $scan = $this->scan($manifest); foreach ($this->referenceRows($manifest) as $i => $row) { - if ($plan['rows'][$i]['status'] === 'reuse') { $reused[] = $row['data']['id']; continue; } - $values = $row['data']; if (isset($values['metadata'])) { $values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR); } + if ($plan['rows'][$i]['status'] === 'reuse') { + $reused[] = $row['data']['id']; + + continue; + } + $values = $row['data']; + if (isset($values['metadata'])) { + $values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR); + } DB::table($row['table'])->insert([...$values, 'created_at' => now(), 'updated_at' => now()]); $changed[] = $values['id']; } } elseif ($operation === 'publish' || $operation === 'withdraw') { - if ($operation === 'publish') { $scan = $this->scan($manifest); } + if ($operation === 'publish') { + $scan = $this->scan($manifest); + } foreach ($this->referenceRows($manifest) as $row) { - $table = $row['table']; $record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first(); + $table = $row['table']; + $record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first(); if (in_array($table, ['reference_catalog_versions', 'economic_activities'], true)) { if ($operation === 'publish' && $record->status === 'draft') { - DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]); $changed[] = $record->id; - } else { $reused[] = $record->id; } + DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]); + $changed[] = $record->id; + } else { + $reused[] = $record->id; + } } else { $visibility = $operation === 'publish' ? 'public' : 'internal'; // Withdrawal touches only objects this importer created, never pre-existing public references. $createdHere = $this->createdHere($record->id); - if ($operation === 'withdraw' && ! $createdHere) { $reused[] = $record->id; continue; } - if ($record->data_classification !== $visibility) { DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]); $changed[] = $record->id; } - else { $reused[] = $record->id; } + if ($operation === 'withdraw' && ! $createdHere) { + $reused[] = $record->id; + + continue; + } + if ($record->data_classification !== $visibility) { + DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]); + $changed[] = $record->id; + } else { + $reused[] = $record->id; + } } } } @@ -280,7 +409,9 @@ class EconomicReferenceInstallation 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'code_hash' => $plan['code_hash'], 'replayed' => false, 'publications' => $operation === 'publish' ? 'references only; no investment or original source publication' : 'none', 'business_records_changed' => 0, 'receipt' => $data['idempotency_key'], 'scan' => $scan ?? null]; - if ($operation === 'import') { $result['previously_public'] = array_values(array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id')); } + if ($operation === 'import') { + $result['previously_public'] = array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id'); + } DB::table('audit_events')->insert(['source_system' => EconomicReferenceCatalog::SOURCE, 'entity_type' => 'economic-reference-batch', 'entity_id' => $manifest['catalog']['id'], 'actor_user_id' => $actor->id, 'correlation_id' => $data['idempotency_key'], 'action' => $operation, 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]); diff --git a/app/Services/EditorialInstallation.php b/app/Services/EditorialInstallation.php index 45432337..8f7b138e 100644 --- a/app/Services/EditorialInstallation.php +++ b/app/Services/EditorialInstallation.php @@ -3,8 +3,10 @@ namespace App\Services; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -12,75 +14,139 @@ use Symfony\Component\HttpKernel\Exception\HttpException; /** Nominal schema installation. No imports, backfill, publication, permissions or flags. */ class EditorialInstallation { - public const SOURCE='editorial-schema-installation'; - public const MIGRATION='2026_10_18_160000_add_portal_editorial_content'; + public const SOURCE = 'editorial-schema-installation'; + + public const MIGRATION = '2026_10_18_160000_add_portal_editorial_content'; + + /** + * @return array + */ public function files(): array { return ['app/Http/Controllers/EditorialArticleController.php', 'app/Http/Controllers/PortalManagementController.php', 'app/Http/Controllers/PortalServicesController.php', 'app/Http/Controllers/PublicPortalController.php', 'app/Services/InvestmentCatalogue.php', 'app/Services/LegacyContent.php', 'app/Services/OpportunityClassification.php', 'routes/web.php', 'database/migrations/2026_10_18_160000_add_portal_editorial_content.php', 'resources/js/pages/management/editor.tsx', 'resources/js/pages/portal/article-editor.tsx', 'resources/js/pages/portal/article.tsx', 'resources/js/pages/portal/document.tsx', 'resources/js/pages/portal/manage.tsx', 'app/Services/EditorialInstallation.php', 'resources/views/management/editorial-installation.blade.php', 'resources/views/management/editorial-mfa.blade.php']; } + public function fingerprint(): string { - $files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files()); - foreach(\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $file){$files[]=['public/build/'.$file->getRelativePathname(),hash_file('sha256',$file->getPathname())];} - usort($files,fn($a,$b)=>strcmp($a[0],$b[0])); - return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR)); + $files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()); + foreach (File::allFiles(public_path('build')) as $file) { + $files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())]; + } + usort($files, fn ($a, $b) => strcmp($a[0], $b[0])); + + return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR)); } - private function authorize(User $actor,bool $write=false): void + + private function authorize(User $actor, bool $write = false): void { - abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'),403,'Gestão autorizada necessária.'); - if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} + abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403, 'Gestão autorizada necessária.'); + if ($write) { + app(PortalProfileInstallation::class)->authorize($actor, true); + } } - private function evidence(array $target,string $code): array + + /** + * @param array $target + * @return array + */ + private function evidence(array $target, string $code): array { - $qa=app()->environment('testing') && (int)$target['port']===55441 && str_ends_with($target['database'],'_test') - && config('database.connections.pgsql.host')==='127.0.0.1' && !config('database.connections.pgsql.url'); - if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];} - if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];} - $path=storage_path('app/private/investmoz-v2/editorial/readiness.json'); - $p=is_file($path)?json_decode(file_get_contents($path),true):[];$date=strtotime($p['verified_at'] ?? '');$auth=$p['active_authorization'] ?? []; - $ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url') - && in_array(config('database.connections.pgsql.host'),['127.0.0.1','localhost'],true) - && ($p['target']['database'] ?? '')===$target['database'] && (int)($p['target']['port'] ?? 0)===(int)$target['port'] - && ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400 + $qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') + && config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url'); + if ($qa) { + return ['ready' => true, 'mode' => 'isolated_QA']; + } + if (app()->environment('testing')) { + return ['ready' => false, 'mode' => 'invalid_QA']; + } + $path = storage_path('app/private/investmoz-v2/editorial/readiness.json'); + $p = is_file($path) ? json_decode(CheckedFile::read($path), true) : []; + $date = strtotime($p['verified_at'] ?? ''); + $auth = $p['active_authorization'] ?? []; + $ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url') + && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true) + && ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port'] + && ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400 && ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false) - && ($p['restored_boot_verified'] ?? false) && (int)($p['restore_target']['port'] ?? 0)===55441 && str_ends_with($p['restore_target']['database'] ?? '','_test') - && ($auth['decision_record'] ?? '')!=='' && ($auth['code_hash'] ?? '')===$code && ($auth['migration'] ?? '')===self::MIGRATION; - foreach(['dump','files'] as $kind){$file=$p[$kind.'_path'] ?? ''; $ready=$ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '',hash_file('sha256',$file));} - return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null]; + && ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test') + && ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === self::MIGRATION; + foreach (['dump', 'files'] as $kind) { + $file = $p[$kind.'_path'] ?? ''; + $ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file)); + } + + return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null]; } + + /** + * @return array + */ public function preview(User $actor): array { - $this->authorize($actor);$target=(array)DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');$code=$this->fingerprint(); - $ledgerCount=DB::table('migrations')->where('migration',self::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('portal_resources','editorial_content'); - $shape=DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='portal_resources' AND column_name='editorial_content'"); - $correct=$shape && $shape->data_type==='jsonb' && $shape->is_nullable==='YES'; - $consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[]; - if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';} - try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();} - $evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';} - $state=['target'=>$target,'migration'=>self::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column, - 'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence]; - return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))]; + $this->authorize($actor); + $target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); + $code = $this->fingerprint(); + $ledgerCount = DB::table('migrations')->where('migration', self::MIGRATION)->count(); + $ledger = $ledgerCount === 1; + $column = Schema::hasColumn('portal_resources', 'editorial_content'); + $shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='portal_resources' AND column_name='editorial_content'"); + $correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES'; + $consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column); + $reasons = []; + if (! $consistent) { + $reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.'; + } + try { + $this->authorize($actor, true); + } catch (HttpException $e) { + $reasons[] = $e->getMessage(); + } + $evidence = $this->evidence($target, $code); + if (! $evidence['ready']) { + $reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.'; + } + $state = ['target' => $target, 'migration' => self::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column, + 'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence]; + + return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))]; } - public function apply(User $actor,array $input): array + + /** + * @param array $input + * @return array + */ + public function apply(User $actor, array $input): array { - $this->authorize($actor,true); - abort_if(array_diff(array_keys($input),['hash','idempotency_key','confirm','_token']),422,'Ator, migration ou caminho arbitrário não permitido.'); - $data=Validator::make($input,['hash'=>'required|string|size:64','idempotency_key'=>'required|uuid','confirm'=>'required|accepted'])->validate(); - return DB::transaction(function()use($actor,$data){ - DB::select('SELECT pg_advisory_xact_lock(181026,160000)');$requestHash=hash('sha256',json_encode($data,JSON_THROW_ON_ERROR)); - $old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first(); - if($old){$receipt=json_decode($old->after_data,true);abort_unless((int)$old->actor_user_id===$actor->id && hash_equals($receipt['request_hash'],$requestHash),409,'Chave já utilizada com outro ator ou conteúdo.');return [...$receipt['result'],'replayed'=>true];} - $plan=$this->preview($actor);abort_unless(hash_equals($plan['hash'],$data['hash']),409,'Prévia alterada; consulte novamente.');abort_unless($plan['ready'],422,implode(' ',$plan['reasons'])); - if(!$plan['installed']){ - config(['editorial.authenticated_installation'=>true]); - try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.self::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');} - finally{config(['editorial.authenticated_installation'=>false]);} + $this->authorize($actor, true); + abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.'); + $data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate(); + + return DB::transaction(function () use ($actor, $data) { + DB::select('SELECT pg_advisory_xact_lock(181026,160000)'); + $requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first(); + if ($old) { + $receipt = json_decode($old->after_data, true); + abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.'); + + return [...$receipt['result'], 'replayed' => true]; } - abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.'); - $result=['migration'=>$plan['migration'],'code_hash'=>$plan['code_hash'],'target'=>$plan['target'],'changed'=>!$plan['installed'],'business_records_changed'=>0,'replayed'=>false,'receipt'=>$data['idempotency_key']]; - DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'portal-editorial-schema','entity_id'=>null,'actor_user_id'=>$actor->id, - 'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]); + $plan = $this->preview($actor); + abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.'); + abort_unless($plan['ready'], 422, implode(' ', $plan['reasons'])); + if (! $plan['installed']) { + config(['editorial.authenticated_installation' => true]); + try { + abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.'); + } finally { + config(['editorial.authenticated_installation' => false]); + } + } + abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.'); + $result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']]; + DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'portal-editorial-schema', 'entity_id' => null, 'actor_user_id' => $actor->id, + 'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]); + return $result; }); } diff --git a/app/Services/EditorialReading.php b/app/Services/EditorialReading.php index 08994f78..9f736bf5 100644 --- a/app/Services/EditorialReading.php +++ b/app/Services/EditorialReading.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Support\CheckedFile; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -11,7 +13,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException; /** Reads verified originals without writing a derived edition or changing publication. */ final class EditorialReading { - public function publicResources() + public function publicResources(): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized') @@ -19,9 +21,12 @@ final class EditorialReading ->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256'); } - public function original(object $resource): ?array + /** + * @return array|null + */ + public function original(\stdClass $resource): ?array { - $document = DB::table('documents')->find($resource->document_id); + $document = DB::table('documents')->where('id', $resource->document_id)->first(); if (! $document || $document->status !== 'active' || $document->malware_scan_status !== 'clean' || ! in_array($document->media_type, ['text/plain', 'application/json'], true)) { return null; } @@ -40,7 +45,11 @@ final class EditorialReading return trim(html_entity_decode(strip_tags(preg_replace('~|~i', "\n\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } - public function content(object $resource, ?array $original): array + /** + * @param array|null $original + * @return array + */ + public function content(\stdClass $resource, ?array $original): array { return $resource->editorial_content ? json_decode($resource->editorial_content, true, 512, JSON_THROW_ON_ERROR) : ['body' => $this->plain($original['content_html'] ?? ''), 'image_resource_id' => null, 'image_mode' => 'original', 'preserve_original_html' => true]; @@ -60,19 +69,23 @@ final class EditorialReading } } - public function reading(object $resource, ?array $override = null, bool $private = false): array + /** + * @param array|null $override + * @return array + */ + public function reading(\stdClass $resource, ?array $override = null, bool $private = false): array { $original = $this->original($resource); $content = $override ?? $this->content($resource, $original); abort_unless(trim($content['body'] ?? '') !== '', 404, 'Este documento não contém um artigo legível.'); - $document = DB::table('documents')->find($resource->document_id); + $document = DB::table('documents')->where('id', $resource->document_id)->first(); // The original is verified above; ordinary text adaptations retain the binary gate too. app(VerifiedDocumentBinary::class)->respond($document); $mode = $content['image_mode'] ?? (! empty($content['image_resource_id']) ? 'selected' : 'none'); $originalImageUrl = $this->sourceUrl($original['image_source_url'] ?? null, $original['source_url'] ?? null); $expectedImageSha = $original['image_sha256'] ?? null; if (! $expectedImageSha && $originalImageUrl === 'https://investemoz.tempo.co.mz/images/tempo-background.png') { - foreach (json_decode(file_get_contents(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $accepted) { + foreach (json_decode(CheckedFile::read(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $accepted) { if ($accepted['sha256'] === $document->sha256) { $expectedImageSha = $accepted['original_image_sha256'] ?? null; break; diff --git a/app/Services/ExistingBusinessClassification.php b/app/Services/ExistingBusinessClassification.php index a2048def..57d83832 100644 --- a/app/Services/ExistingBusinessClassification.php +++ b/app/Services/ExistingBusinessClassification.php @@ -6,6 +6,7 @@ use App\Models\ExistingBusiness; use App\Models\User; use App\Policies\ExistingBusinessPolicy; use App\Services\Mne\BusinessRepository; +use App\Support\StructuredData; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; @@ -19,8 +20,12 @@ use Illuminate\Validation\ValidationException; class ExistingBusinessClassification { public const VERSION = 'business-classification-20261016-v1'; + private const ACTIONS = ['classification_prepared', 'classification_reviewed', 'classification_disclosed', 'classification_withdrawn']; + /** + * @return array + */ public function context(ExistingBusiness $business): array { $revision = DB::table('existing_business_revisions')->where('business_id', $business->id)->whereIn('action', self::ACTIONS)->orderByDesc('version_no')->first(['snapshot']); @@ -33,11 +38,18 @@ class ExistingBusinessClassification return $state; } + /** + * @param array $row + */ private function hash(array $row): string { return OpportunityCanonicalValue::hash($row); } + /** + * @param array $row + * @return array|null + */ private function evidence(array $row, ExistingBusiness $business): ?array { $id = $row['classification']['document_id'] ?? null; @@ -51,6 +63,9 @@ class ExistingBusinessClassification return $document ? (array) $document : ['unavailable' => $id]; } + /** + * @param array $row + */ private function evidenceHash(array $row, ExistingBusiness $business): ?string { $evidence = $this->evidence($row, $business); @@ -58,6 +73,9 @@ class ExistingBusinessClassification return $evidence === null ? null : OpportunityCanonicalValue::hash($evidence); } + /** + * @param array $rows + */ private function validateRows(array $rows, ExistingBusiness $business, bool $confirming = false): void { $seen = []; @@ -80,7 +98,8 @@ class ExistingBusinessClassification if (array_diff(array_keys($row), ['related_id', 'relation_type', 'classification'])) { $fail('classification', 'Metadados de decisão ou campos desconhecidos não podem ser enviados pelo autor.'); } - $c = $row['classification']; $kind = $c['target_type']; + $c = $row['classification']; + $kind = $c['target_type']; if (($kind === 'sector' && ! ctype_digit($row['related_id'])) || ($kind !== 'sector' && ! Str::isUuid($row['related_id']))) { $fail('related_id', 'Identificador de referência inválido.'); } @@ -91,7 +110,9 @@ class ExistingBusinessClassification $fail('classification.purpose', 'Regime, admissão e benefício usam a relação regulatória com a zona.'); } $identity = OpportunityClassification::TARGETS[$kind][0].':'.$row['related_id'].':'.$c['purpose']; - if (isset($seen[$identity])) { $fail('related_id', 'Referência e finalidade já associadas; recupere a relação existente.'); } + if (isset($seen[$identity])) { + $fail('related_id', 'Referência e finalidade já associadas; recupere a relação existente.'); + } $seen[$identity] = true; if (! empty($c['valid_from']) && ! empty($c['valid_until']) && $c['valid_until'] < $c['valid_from']) { $fail('classification.valid_until', 'O fim da vigência precede o início.'); @@ -100,13 +121,17 @@ class ExistingBusinessClassification $fail('classification.authority_id', 'Entidade competente não consta do catálogo publicado.'); } $evidence = $this->evidence($row, $business); - if ($evidence && isset($evidence['unavailable'])) { $fail('classification.document_id', 'Documento não está associado a este anúncio.'); } + if ($evidence && isset($evidence['unavailable'])) { + $fail('classification.document_id', 'Documento não está associado a este anúncio.'); + } if (($c['external_status'] ?? '') === 'documented_granted' && (empty($c['document_id']) || empty(trim($c['external_act_reference'] ?? '')))) { $fail('classification.external_act_reference', 'Um ato declarado como concedido exige documento e referência próprios.'); } if ($confirming) { foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) { - if (empty(trim($c[$key] ?? ''))) { $fail('classification.'.$key, 'Confirmação exige fundamento e fonte/citação por relação; a preparação pode continuar preliminar.'); } + if (empty(trim($c[$key] ?? ''))) { + $fail('classification.'.$key, 'Confirmação exige fundamento e fonte/citação por relação; a preparação pode continuar preliminar.'); + } } if ($evidence && ($evidence['status'] !== 'active' || $evidence['deleted_at'] !== null || $evidence['malware_scan_status'] !== 'clean' || ! hash_equals(trim($evidence['sha256']), trim((string) $evidence['malware_scanned_sha256'])) @@ -117,13 +142,21 @@ class ExistingBusinessClassification } } + /** + * @param array $row + * @param array $state + */ private function effective(ExistingBusiness $business, array $row, array $state): string { $decision = $state['decisions'][$this->hash($row)] ?? null; - if (! $decision) { return $row['classification']['requested_state'] ?? 'preliminary'; } - if ($decision['state'] !== 'confirmed') { return $decision['state']; } + if (! $decision) { + return $row['classification']['requested_state'] ?? 'preliminary'; + } + if ($decision['state'] !== 'confirmed') { + return $decision['state']; + } $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; - $reviewer = User::find($decision['actor_id']); + $reviewer = User::whereKey($decision['actor_id'])->first(); if (! $reviewer || ! app(ExistingBusinessPolicy::class)->classificationReviewerScope($reviewer, $business, $dimension) || (int) ($state['authors'][$this->hash($row)] ?? 0) === (int) $reviewer->id || ($decision['evidence_hash'] ?? null) !== $this->evidenceHash($row, $business)) { @@ -133,13 +166,17 @@ class ExistingBusinessClassification return 'confirmed'; } + /** + * @return array + */ public function privateView(User $actor, ExistingBusiness $business): array { Gate::forUser($actor)->authorize('view', $business); $state = $this->context($business); $rows = []; foreach ($state['relations'] as $row) { - $hash = $this->hash($row); $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; + $hash = $this->hash($row); + $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; $rows[] = ['hash' => $hash, 'relation' => $row, 'dimension' => $dimension, 'state' => $this->effective($business, $row, $state), 'can_review' => Gate::forUser($actor)->allows('reviewClassification', $business) && app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension) && (int) ($state['authors'][$hash] ?? 0) !== (int) $actor->id, @@ -158,6 +195,9 @@ class ExistingBusinessClassification 'documents' => $documents->map(fn ($d) => ['id' => $d->id, 'payload' => ['title' => $d->file_name], 'file' => ['file_name' => $d->file_name]])]; } + /** + * @param array $input + */ public function act(User $actor, string $id, array $input): ExistingBusiness { $data = Validator::make($input, [ @@ -167,26 +207,32 @@ class ExistingBusinessClassification 'hash' => ['required_if:action,review', 'string', 'size:64'], 'state' => ['required_if:action,review', Rule::in(['in_validation', 'confirmed', 'rejected', 'archived', 'revoked'])], ], ['reason.required' => 'Indique o fundamento da ação.', 'reason.min' => 'O fundamento deve ter pelo menos 20 caracteres.'])->validate(); - abort_if(array_diff(array_keys($input), ['action', 'version_no', 'reason', 'rows', 'hash', 'state']), 422, 'Esta ação aceita apenas campos da classificação.'); + abort_if(array_diff(array_keys($input), ['action', 'version_no', 'reason', 'rows', 'hash', 'state']) !== [], 422, 'Esta ação aceita apenas campos da classificação.'); return DB::transaction(function () use ($actor, $id, $data) { $business = app(BusinessRepository::class)->find($id, true); $action = $data['action']; - Gate::forUser($actor)->authorize(match ($action) { 'prepare' => 'prepareClassification', 'review' => 'reviewClassification', default => 'discloseClassification' }, $business); + Gate::forUser($actor)->authorize(match ($action) { + 'prepare' => 'prepareClassification', 'review' => 'reviewClassification', default => 'discloseClassification' + }, $business); abort_unless($business->version_no === (int) $data['version_no'], 409, 'A versão mudou. Atualize antes de decidir; preserve os valores preparados.'); $state = $this->context($business); if ($action === 'prepare') { $this->validateRows($data['rows'], $business); $state['relations'] = $data['rows']; - foreach ($data['rows'] as $row) { $state['authors'][$this->hash($row)] ??= $actor->id; } + foreach ($data['rows'] as $row) { + $state['authors'][$this->hash($row)] ??= $actor->id; + } } elseif ($action === 'review') { $search = in_array($data['state'], ['revoked', 'archived'], true) ? [...$state['relations'], ...$state['publication']] : $state['relations']; - $row = collect($search)->first(fn ($r) => hash_equals($this->hash($r), $data['hash'])); + $row = collect(StructuredData::collectionItems($search))->first(fn ($r) => hash_equals($this->hash($r), $data['hash'])); abort_unless($row, 409, 'Associação inexistente nesta versão.'); $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; abort_unless(app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension) && (int) ($state['authors'][$data['hash']] ?? 0) !== (int) $actor->id, 403, 'Revisão exige responsabilidade e independência aplicáveis.'); - if ($data['state'] === 'confirmed') { $this->validateRows([$row], $business, true); } + if ($data['state'] === 'confirmed') { + $this->validateRows([$row], $business, true); + } $prior = $state['decisions'][$data['hash']] ?? []; $decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'], 'evidence_hash' => $this->evidenceHash($row, $business), 'version' => ($prior['version'] ?? 0) + 1]; @@ -216,21 +262,35 @@ class ExistingBusinessClassification }); } + /** + * @param array|null $preparedState + * @return array + */ public function publicRows(ExistingBusiness $business, ?array $preparedState = null): array { if ($business->trashed() || $business->status !== 'published' || ! $business->published_at || $business->published_at->isFuture() - || ($business->expires_at && $business->expires_at->isBefore(today()))) { return []; } - $state = $preparedState ?? $this->context($business); $rows = []; + || ($business->expires_at && $business->expires_at->isBefore(today()))) { + return []; + } + $state = $preparedState ?? $this->context($business); + $rows = []; foreach ($state['publication'] as $row) { - $c = $row['classification']; $kind = $c['target_type']; - if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) { continue; } + $c = $row['classification']; + $kind = $c['target_type']; + if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) { + continue; + } $target = app(OpportunityClassification::class)->targets($kind)->where('id', $row['related_id'])->first(); - if (! $target || (! empty($c['valid_from']) && $c['valid_from'] > today()->toDateString()) || (! empty($c['valid_until']) && $c['valid_until'] < today()->toDateString())) { continue; } + if (! $target || (! empty($c['valid_from']) && $c['valid_from'] > today()->toDateString()) || (! empty($c['valid_until']) && $c['valid_until'] < today()->toDateString())) { + continue; + } $effective = $this->effective($business, $row, $state); - if (in_array($effective, ['archived', 'revoked'], true)) { continue; } + if (in_array($effective, ['archived', 'revoked'], true)) { + continue; + } $rows[] = ['dimension' => OpportunityClassification::TARGETS[$kind][2], 'target_type' => $kind, 'target_id' => (string) $row['related_id'], 'label' => $target->{OpportunityClassification::TARGETS[$kind][1]}, 'target_code' => $target->code ?? null, - 'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)?->path ?? []) : [], 'purpose' => $c['purpose'], 'purpose_label' => OpportunityClassification::PURPOSES[$c['purpose']], + 'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)->path ?? []) : [], 'purpose' => $c['purpose'], 'purpose_label' => OpportunityClassification::PURPOSES[$c['purpose']], 'state' => $effective, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null, 'authority_id' => $c['authority_id'] ?? null, 'production_stage' => $c['production_stage'] ?? null, 'external_status' => $c['external_status'], 'valid_from' => $c['valid_from'] ?? null, 'valid_until' => $c['valid_until'] ?? null]; @@ -239,6 +299,9 @@ class ExistingBusinessClassification return $rows; } + /** + * @return array + */ public function filterRules(): array { return ['association_type' => ['nullable', Rule::in(array_keys(OpportunityClassification::TARGETS))], @@ -247,25 +310,41 @@ class ExistingBusinessClassification 'chain_id' => ['nullable', 'uuid'], 'authority_id' => ['nullable', 'uuid'], 'subsector_id' => ['nullable', 'uuid']]; } + /** + * @param array $filters + * @param Builder $query + */ public function constrain(Builder $query, array $filters): void { $filters = array_filter(array_intersect_key($filters, $this->filterRules()), fn ($v) => $v !== null && $v !== ''); - if ($filters === []) { return; } + if ($filters === []) { + return; + } $ids = []; (clone $query)->reorder()->select('existing_businesses.*')->chunkById(200, function ($records) use ($filters, &$ids) { foreach ($records as $business) { - $rows = $this->publicRows($business); $same = array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_purpose', 'association_state'])); + $rows = $this->publicRows($business); + $same = array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_purpose', 'association_state'])); $matches = ! $same || collect($rows)->contains(function ($row) use ($same) { foreach (['association_type' => 'target_type', 'association_target' => 'target_id', 'association_purpose' => 'purpose', 'association_state' => 'state'] as $key => $field) { - if (isset($same[$key]) && $same[$key] !== $row[$field]) { return false; } + if (isset($same[$key]) && $same[$key] !== $row[$field]) { + return false; + } } + return true; }); foreach (['chain_id' => 'chain', 'subsector_id' => 'activity'] as $key => $type) { - if (! empty($filters[$key])) { $matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === $type && $r['target_id'] === $filters[$key]); } + if (! empty($filters[$key])) { + $matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === $type && $r['target_id'] === $filters[$key]); + } + } + if (! empty($filters['authority_id'])) { + $matches = $matches && collect($rows)->contains(fn ($r) => ($r['authority_id'] ?? null) === $filters['authority_id']); + } + if ($matches) { + $ids[] = $business->id; } - if (! empty($filters['authority_id'])) { $matches = $matches && collect($rows)->contains(fn ($r) => ($r['authority_id'] ?? null) === $filters['authority_id']); } - if ($matches) { $ids[] = $business->id; } } }, 'existing_businesses.id', 'id'); $query->whereIn('existing_businesses.id', $ids); diff --git a/app/Services/ExistingBusinessProfileCategory.php b/app/Services/ExistingBusinessProfileCategory.php index 0e7cb8d3..28760800 100644 --- a/app/Services/ExistingBusinessProfileCategory.php +++ b/app/Services/ExistingBusinessProfileCategory.php @@ -1,4 +1,5 @@ 'business_sale', 'participation_sale'=>'business_sale', - 'buyer_search'=>'business_sale', 'succession'=>'business_sale', - 'franchise'=>'franchise', 'real_estate'=>'real_estate', 'land_right'=>'land_rights', - 'investment_project'=>'investment_project', 'expansion'=>'investment_project', - 'internationalization'=>'investment_project', 'agricultural_investment'=>'investment_project', - 'business_partnership'=>'business_partnership', 'strategic_partner'=>'business_partnership', - 'joint_venture'=>'business_partnership', 'capital_increase'=>'financing', - 'financial_investor'=>'financing', 'financing_request'=>'financing', - 'concession_opportunity'=>'concession', 'supply_opportunity'=>'supply', + 'company_sale' => 'business_sale', 'participation_sale' => 'business_sale', + 'buyer_search' => 'business_sale', 'succession' => 'business_sale', + 'franchise' => 'franchise', 'real_estate' => 'real_estate', 'land_right' => 'land_rights', + 'investment_project' => 'investment_project', 'expansion' => 'investment_project', + 'internationalization' => 'investment_project', 'agricultural_investment' => 'investment_project', + 'business_partnership' => 'business_partnership', 'strategic_partner' => 'business_partnership', + 'joint_venture' => 'business_partnership', 'capital_increase' => 'financing', + 'financial_investor' => 'financing', 'financing_request' => 'financing', + 'concession_opportunity' => 'concession', 'supply_opportunity' => 'supply', ]; // Native form types are separate from the persisted modality. No default to a project. public const FORM_TYPES = [ - 'investment_project'=>'investment_project', 'company_sale'=>'company_sale', - 'asset_sale'=>'asset_sale', 'business_partnership'=>'business_partnership', - 'financing_request'=>'financing_request', 'concession_opportunity'=>'concession_opportunity', - 'supply_opportunity'=>'supply_opportunity', 'participation_sale'=>'company_sale', - 'asset_lease'=>'asset_sale', 'capital_increase'=>'financing_request', - 'financial_investor'=>'financing_request', 'strategic_partner'=>'business_partnership', - 'joint_venture'=>'business_partnership', 'expansion'=>'investment_project', - 'agricultural_investment'=>'investment_project', 'internationalization'=>'investment_project', - 'buyer_search'=>'company_sale', 'succession'=>'company_sale', + 'investment_project' => 'investment_project', 'company_sale' => 'company_sale', + 'asset_sale' => 'asset_sale', 'business_partnership' => 'business_partnership', + 'financing_request' => 'financing_request', 'concession_opportunity' => 'concession_opportunity', + 'supply_opportunity' => 'supply_opportunity', 'participation_sale' => 'company_sale', + 'asset_lease' => 'asset_sale', 'capital_increase' => 'financing_request', + 'financial_investor' => 'financing_request', 'strategic_partner' => 'business_partnership', + 'joint_venture' => 'business_partnership', 'expansion' => 'investment_project', + 'agricultural_investment' => 'investment_project', 'internationalization' => 'investment_project', + 'buyer_search' => 'company_sale', 'succession' => 'company_sale', ]; public static function category(ExistingBusiness $business): ?string diff --git a/app/Services/FintechInvestment.php b/app/Services/FintechInvestment.php index 4cde1343..7e9036e0 100644 --- a/app/Services/FintechInvestment.php +++ b/app/Services/FintechInvestment.php @@ -38,6 +38,9 @@ final class FintechInvestment public function __construct(private readonly VirtualDesk $desk) {} + /** + * @param array $input + */ public function save(User $actor, string $id, array $input): void { $keys = implode(',', array_keys(self::FIELDS)); @@ -89,6 +92,9 @@ final class FintechInvestment }); } + /** + * @param array $value + */ private function fingerprint(array $value): string { $canonical = function ($v) use (&$canonical) { @@ -105,6 +111,10 @@ final class FintechInvestment return hash('sha256', json_encode($canonical($value), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); } + /** + * @param array $record + * @return array + */ public function reviewFingerprints(array $record): array { $profile = $record['profile'] ?? []; @@ -121,6 +131,11 @@ final class FintechInvestment ]; } + /** + * @param array $payload + * @param array $steps + * @return array + */ public function adjustSteps(array $payload, array $steps): array { $record = $payload['investor_path']['fintech'] ?? null; @@ -145,6 +160,11 @@ final class FintechInvestment return $steps; } + /** + * @param array $payload + * @param array $steps + * @return array + */ public function view(array $payload, array $steps): array { $record = $payload['investor_path']['fintech'] ?? null; @@ -174,7 +194,7 @@ final class FintechInvestment return ['guidance' => $guidance, 'guidance_version' => InvestorGuidanceRules::VERSION, 'schema' => self::VERSION, 'record' => $record, 'fields' => self::FIELDS, 'activities' => self::ACTIVITIES, 'countries' => app(CountryCatalogue::class)->all(), 'requirements' => $requirements, 'documents' => $docs, 'review_fingerprints' => $record ? $this->reviewFingerprints($record) : [], - 'affected_reviews' => array_values(array_column(array_filter($steps,fn ($s) => $s['fintech_review_required'] ?? false), 'code')), + 'affected_reviews' => array_column(array_filter($steps, fn ($s) => $s['fintech_review_required'] ?? false), 'code'), 'limits' => ['Fintech é caracterização do investimento, não nova categoria jurídica ou autorização.', 'Preparação de orçamento, tecnologia, documentos e parceiros pode avançar em paralelo.', 'Países de operação não alteram país mestre, origem, nacionalidade ou residências.', 'Capitais mínimos, taxas, prazos e licenças específicos permanecem por determinar sem disposição e período aplicáveis.']]; } } diff --git a/app/Services/InterestWorkflow.php b/app/Services/InterestWorkflow.php index 0abb91f9..ab1288a5 100644 --- a/app/Services/InterestWorkflow.php +++ b/app/Services/InterestWorkflow.php @@ -41,7 +41,7 @@ final class InterestWorkflow return DB::transaction(function () use ($actor, $interest, $status, $note, $nextAction, $dueAt, $assigneeId, $assignmentProvided, $actionProvided) { $locked = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id); - abort_unless(app(ScopedPortalAdministration::class)->interest($actor,$locked),403); + abort_unless(app(ScopedPortalAdministration::class)->interest($actor, $locked), 403); $from = $locked->status; $actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null; $followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 54cfbaed..bc0b54c5 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -6,6 +6,7 @@ use App\Http\Controllers\InvestmentPortfolioController; use App\Models\InvestmentDossier; use App\Models\Opportunity; use App\Models\Sector; +use App\Support\StructuredData; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -239,7 +240,8 @@ class InvestmentCatalogue }; } - private function declaredProvince($query, string $name): void + /** @param Builder $query */ + private function declaredProvince(Builder $query, string $name): void { // Read the published province declaration only when no more precise territory contradicts it. // This does not assign any canonical ID, district, post, locality or coordinates to the record. @@ -249,7 +251,8 @@ class InvestmentCatalogue ->whereRaw('lower(btrim(opportunities.province)) = ?', [mb_strtolower(trim($name))])); } - private function approvedCorridorProvince($query, string $provinceId): void + /** @param Builder $query */ + private function approvedCorridorProvince(Builder $query, string $provinceId): void { $code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code'); $names = []; @@ -344,8 +347,8 @@ class InvestmentCatalogue // A chain's documented scope comes from its public cluster, never // from the opportunities surviving the current search. $ids = app(OpportunityClassification::class)->targets('chain') - ->whereIn('cluster_id', $result['clusters']->pluck('id'))->pluck('id'); - $result['classification']['targets']['chain'] = collect($result['classification']['targets']['chain'])->whereIn('id', $ids)->values(); + ->whereIn('cluster_id', ($result['clusters'] ?? collect())->pluck('id'))->pluck('id'); + $result['classification']['targets']['chain'] = collect(StructuredData::collectionItems($result['classification']['targets']['chain']))->whereIn('id', $ids)->values(); } // Activities are a public classification catalogue, not evidence of // investment availability in a territory or sector. @@ -353,6 +356,10 @@ class InvestmentCatalogue return $result; } + /** + * @param array $filters + * @return array + */ private function publicAssociatedIds(array $filters): array { $kinds = ['park_id' => ['park'], 'zee_id' => ['zone'], 'zfi_id' => ['zone'], diff --git a/app/Services/InvestmentComplements.php b/app/Services/InvestmentComplements.php index e1463b03..da6c4ade 100644 --- a/app/Services/InvestmentComplements.php +++ b/app/Services/InvestmentComplements.php @@ -4,9 +4,11 @@ namespace App\Services; use App\Http\Controllers\InvestmentPortfolioController; use App\Models\Opportunity; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Symfony\Component\HttpKernel\Exception\HttpException; /** Resolve documented complements without creating opportunities or master relations. */ final class InvestmentComplements @@ -23,6 +25,9 @@ final class InvestmentComplements 'finance' => ['Financiamento', 'financiamento|serviços financeiros'], ]; + /** + * @return array + */ public function options(string $origin, ?string $territory = null): array { [$originKind, $originId] = array_pad(explode(':', $origin, 2), 2, ''); @@ -30,18 +35,24 @@ final class InvestmentComplements $principal = null; $relationEvidence = []; if (str_starts_with($origin, 'portfolio:')) { - $record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail(substr($origin, 10)); + $record = InvestmentPortfolioController::visible()->addSelect('version_no')->whereKey(substr($origin, 10))->firstOrFail(); $text = trim(($record->content['focus'] ?? '')."\n".($record->content['gaps'] ?? '')."\n".($record->content['description'] ?? '')); $reference = ['key' => $origin, 'title' => $record->title, 'href' => '/carteira/'.$record->id, 'version' => $record->version_no]; $ids = app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record); foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { - if ($record->$foreign) $ids = [...$ids, ...DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->pluck('opportunity_id')->all()]; + if ($record->$foreign) { + $ids = [...$ids, ...DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->pluck('opportunity_id')->all()]; + } } $basis = $record->source; - foreach (array_unique($ids) as $id) $relationEvidence[$id] = ['type' => 'portfolio', 'origin' => $origin, 'source' => $basis]; + foreach (array_unique($ids) as $id) { + $relationEvidence[$id] = ['type' => 'portfolio', 'origin' => $origin, 'source' => $basis]; + } $serviceOrigins = [['dossier', $record->id]]; foreach (['project_id' => 'project', 'cluster_id' => 'cluster'] as $foreign => $type) { - if ($record->$foreign) $serviceOrigins[] = [$type, $record->$foreign]; + if ($record->$foreign) { + $serviceOrigins[] = [$type, $record->$foreign]; + } } } else { $reference = app(InvestorContext::class)->reference($origin); @@ -49,8 +60,10 @@ final class InvestmentComplements // The context resolver has already checked the actor's private scope. // A private preparation is valid dossier input, not a public offer // from which this public-complement catalogue can infer services. - if (($reference['visibility'] ?? 'public') === 'private') return []; - $principal = Opportunity::published()->findOrFail($reference['id']); + if (($reference['visibility'] ?? 'public') === 'private') { + return []; + } + $principal = Opportunity::published()->whereKey($reference['id'])->firstOrFail(); $text = $principal->summary; $basis = 'Resumo cadastrado da oportunidade '.$principal->reference; $projects = DB::table('opportunity_projects')->where('opportunity_id', $principal->id)->pluck('project_id'); @@ -59,18 +72,24 @@ final class InvestmentComplements $targetProjects = DB::table('opportunity_projects')->whereIn('project_id', $links->pluck('complementary_project_id'))->get(); $ids = [$principal->id, ...$targetProjects->pluck('opportunity_id')->all()]; $relationEvidence[$principal->id] = ['type' => 'same_fiche', 'source' => $basis]; - foreach ($targetProjects as $targetProject) $relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project', - 'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn($link) => (array)$link)->all()]; - foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) $text .= "\n".$linked->summary; - if (count($ids) > 1) $basis .= '; relações complementares públicas entre projectos'; + foreach ($targetProjects as $targetProject) { + $relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project', + 'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn ($link) => (array) $link)->all()]; + } + foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) { + $text .= "\n".$linked->summary; + } + if (count($ids) > 1) { + $basis .= '; relações complementares públicas entre projectos'; + } $serviceOrigins = [['investment', (string) $principal->id]]; $classification = app(OpportunityClassification::class); - $principalChains = collect($classification->publicRows($principal))->where('target_type', 'chain')->filter(fn($r) => !empty($r['source_reference'])); + $principalChains = collect($classification->publicRows($principal))->where('target_type', 'chain')->filter(fn ($r) => ! empty($r['source_reference'])); $chains = $principalChains->pluck('target_id')->all(); if ($chains) { Opportunity::published()->whereNot('id', $principal->id)->chunkById(100, function ($records) use (&$ids, &$relationEvidence, $chains, $principalChains, $classification) { foreach ($records as $candidate) { - $related = collect($classification->publicRows($candidate))->where('target_type', 'chain')->filter(fn($r) => !empty($r['source_reference'])); + $related = collect($classification->publicRows($candidate))->where('target_type', 'chain')->filter(fn ($r) => ! empty($r['source_reference'])); $shared = array_intersect($chains, $related->pluck('target_id')->all()); if ($shared) { $ids[] = $candidate->id; @@ -84,15 +103,25 @@ final class InvestmentComplements $candidates = Opportunity::published()->whereIn('id', array_unique($ids))->get(); $items = []; foreach (self::TERMS as $code => [$label, $pattern]) { - if (!preg_match('/\b(?:'.$pattern.')\b/iu', $text, $match)) continue; + if (! preg_match('/\b(?:'.$pattern.')\b/iu', $text, $match)) { + continue; + } $offers = []; foreach ($candidates as $candidate) { // A common corridor alone does not prove the component. Keep the supporting excerpt. - if (!preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title."\n".$candidate->summary, $evidence)) continue; + if (! preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title."\n".$candidate->summary, $evidence)) { + continue; + } // Mentioning a need for funding/training in a project's summary is not an offer. - if (in_array($code, ['certification', 'training', 'finance'], true) && !preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title)) continue; - if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) continue; - if ($territory && $candidate->primary_territory_id && !$this->compatible($territory, $candidate->primary_territory_id)) continue; + if (in_array($code, ['certification', 'training', 'finance'], true) && ! preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title)) { + continue; + } + if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) { + continue; + } + if ($territory && $candidate->primary_territory_id && ! $this->compatible($territory, $candidate->primary_territory_id)) { + continue; + } $offers[] = ['key' => 'opportunity:'.$candidate->id, 'title' => $candidate->title, 'href' => '/oportunidades/'.$candidate->slug, 'relationship' => $relationEvidence[$candidate->id] ?? null, 'version' => $candidate->management_version, 'evidence' => $evidence[0], 'location' => ($principal && $principal->id === $candidate->id ? 'Componente referido na própria ficha. ' : '').($candidate->primary_territory_id ? app(InvestorContext::class)->reference('territory:'.$candidate->primary_territory_id)['title'] : 'Implantação por definir')]; @@ -100,62 +129,80 @@ final class InvestmentComplements // Services are concrete published offerings explicitly associated with this source. $services = []; if (in_array($code, ['certification', 'training', 'finance'], true) && Schema::hasTable('partnership_origins') && Schema::hasTable('partnership_interests')) { - if ($serviceOrigins) { - $services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id') - ->where(function ($q) use ($serviceOrigins) { - foreach ($serviceOrigins as [$type, $id]) $q->orWhere(fn($q) => $q->where('o.type', $type)->where('o.target_id', $id)); - })->where('p.status', 'published')->where('p.kind', 'offering') - ->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training') - ->when($code !== 'finance', fn($q) => $q->where(fn($q) => $q->where('p.title', 'ilike', '%'.$match[0].'%')->orWhereRaw("p.payload->>'summary' ILIKE ?", ['%'.$match[0].'%'])))->distinct()->get(['p.id', 'p.title']) - ->filter(function ($p) use ($territory) { - if (!$territory) return true; - $locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id'); - return $locations->isEmpty() || $locations->contains(fn($id) => $this->compatible($territory, $id)); - })->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all(); - } + $services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id') + ->where(function ($q) use ($serviceOrigins) { + foreach ($serviceOrigins as [$type, $id]) { + $q->orWhere(fn ($q) => $q->where('o.type', $type)->where('o.target_id', $id)); + } + })->where('p.status', 'published')->where('p.kind', 'offering') + ->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training') + ->when($code !== 'finance', fn ($q) => $q->where(fn ($q) => $q->where('p.title', 'ilike', '%'.$match[0].'%')->orWhereRaw("p.payload->>'summary' ILIKE ?", ['%'.$match[0].'%'])))->distinct()->get(['p.id', 'p.title']) + ->filter(function ($p) use ($territory) { + if (! $territory) { + return true; + } + $locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id'); + + return $locations->isEmpty() || $locations->contains(fn ($id) => $this->compatible($territory, $id)); + })->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all(); } - $excerpt = collect(explode("\n", $text))->first(fn($line) => preg_match('/\b(?:'.$pattern.')\b/iu', $line)); + $excerpt = collect(explode("\n", $text))->first(fn ($line) => preg_match('/\b(?:'.$pattern.')\b/iu', $line) === 1); $items[] = ['code' => $code, 'title' => $label, 'origin' => $reference, 'territory_id' => $territory, 'territory' => $territoryReference, 'source' => $basis, 'excerpt' => $excerpt, 'source_sha256' => hash('sha256', $text), 'opportunities' => $offers, 'services' => $services]; } + return $items; } private function compatible(string $a, string $b): bool { - if ($a === $b) return true; + if ($a === $b) { + return true; + } $geo = app(InvestmentGeography::class); + return in_array($b, $geo->descendants($a), true) || in_array($a, $geo->descendants($b), true); } + /** + * @param array $input + * @param array $previous + * @return array + */ public function resolve(array $input, array $previous = []): array { $resolved = []; foreach ($input as $choice) { $target = $choice['target'] ?? null; - $old = collect($previous)->first(fn($r) => $r['origin']['key'] === $choice['origin'] && $r['code'] === $choice['code'] && ($r['target']['key'] ?? null) === $target && ($r['territory_id'] ?? null) === ($choice['territory_id'] ?? null)); + $old = collect($previous)->first(fn ($r) => $r['origin']['key'] === $choice['origin'] && $r['code'] === $choice['code'] && ($r['target']['key'] ?? null) === $target && ($r['territory_id'] ?? null) === ($choice['territory_id'] ?? null)); try { $item = collect($this->options($choice['origin'], $choice['territory_id'] ?? null))->firstWhere('code', $choice['code']); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { - if (!$old) throw $e; + } catch (ModelNotFoundException $e) { + if (! $old) { + throw $e; + } $item = null; - } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { - if (!$old || $e->getStatusCode() !== 404) throw $e; + } catch (HttpException $e) { + if (! $old || $e->getStatusCode() !== 404) { + throw $e; + } $item = null; } $offer = $target && $item ? collect([...$item['opportunities'], ...$item['services']])->firstWhere('key', $target) : null; - if ($old && (!$item || ($target && !$offer) || (!$target && ($item['opportunities'] || $item['services'])))) { + if ($old && (! $item || ($target && ! $offer) || (! $target && ($item['opportunities'] || $item['services'])))) { $resolved[$old['key']] = [...$old, 'availability' => 'review_required']; + continue; } abort_unless($item, 422, 'Complemento sem referência documentada neste investimento.'); - abort_if($target && !$offer, 422, 'A oferta complementar deixou de estar disponível neste contexto.'); - abort_if(!$target && ($item['opportunities'] || $item['services']), 422, 'Já existe uma oferta relacionada; seleccione a ficha correspondente.'); + abort_if($target && ! $offer, 422, 'A oferta complementar deixou de estar disponível neste contexto.'); + abort_if(! $target && ($item['opportunities'] || $item['services']), 422, 'Já existe uma oferta relacionada; seleccione a ficha correspondente.'); $key = hash('sha256', $choice['origin'].'|'.$choice['code'].'|'.($target ?? 'idea').'|'.($choice['territory_id'] ?? '')); $resolved[$key] = ['key' => $key, 'origin' => $item['origin'], 'code' => $item['code'], 'title' => $item['title'], 'kind' => $target ? (str_starts_with($target, 'opportunity:') ? 'opportunity' : 'service') : 'need', 'target' => $offer, 'territory_id' => $item['territory_id'], 'territory' => $item['territory'], 'source' => $item['source'], 'excerpt' => $item['excerpt'], 'source_sha256' => $item['source_sha256']]; } + return array_values($resolved); } } diff --git a/app/Services/InvestmentConsumerScope.php b/app/Services/InvestmentConsumerScope.php index 82cae6cd..14a0c270 100644 --- a/app/Services/InvestmentConsumerScope.php +++ b/app/Services/InvestmentConsumerScope.php @@ -3,83 +3,123 @@ namespace App\Services; use App\Models\User; +use App\Support\StructuredData; use Illuminate\Support\Arr; /** An identified opportunity within the authorised dossier, never the account's last choice. */ final class InvestmentConsumerScope { + /** + * @param array $context + */ public function key(array $context, ?string $key, bool $required = false): ?string { $choices = $context['opportunity_choices'] ?? []; $key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null); - abort_if($key && !collect($choices)->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.'); - abort_if($required && count($choices) > 1 && !$key, 422, 'Escolha a oportunidade em tratamento.'); + abort_if($key && ! collect(StructuredData::collectionItems($choices))->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.'); + abort_if($required && count($choices) > 1 && ! $key, 422, 'Escolha a oportunidade em tratamento.'); + return $key; } + /** + * @param array $context + * @param array $payload + * @return array + */ public function facts(array $payload, array $context, ?string $key): array { $key = $this->key($context, $key); $path = $payload['investor_path'] ?? []; $defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown']; // A dossier-wide dispensation cannot decide a distinct opportunity's needs. - if ($key && count($context['opportunity_choices'] ?? []) > 1) return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])]; - if ($key && isset($path['opportunity_facts'][$key])) return [...$defaults, ...$path['opportunity_facts'][$key]]; - if (count($context['opportunity_choices'] ?? []) > 1) return $defaults; + if ($key && count($context['opportunity_choices'] ?? []) > 1) { + return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])]; + } + if ($key && isset($path['opportunity_facts'][$key])) { + return [...$defaults, ...$path['opportunity_facts'][$key]]; + } + if (count($context['opportunity_choices'] ?? []) > 1) { + return $defaults; + } + return [...$defaults, ...($path['facts'] ?? [])]; } + /** + * @param array $context + * @return array + */ public function context(array $context, ?string $key): array { $choices = $context['opportunity_choices'] ?? []; - if (!$choices) return $context; + if (! $choices) { + return $context; + } $key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null); - $choice = $key ? collect($choices)->firstWhere('opportunity.key', $key) : null; - abort_if($key && !$choice, 422, 'Escolha uma oportunidade deste dossier.'); + $choice = $key ? collect(StructuredData::collectionItems($choices))->firstWhere('opportunity.key', $key) : null; + abort_if($key && ! $choice, 422, 'Escolha uma oportunidade deste dossier.'); $specific = ['project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'special_zone_id', 'investment_regime']; $context['fields'] = Arr::except($context['fields'], $specific); $context['provenance'] = Arr::except($context['provenance'] ?? [], $specific); $context['selection'] = Arr::except($context['selection'], ['opportunity', 'territory', 'park']); - if (!$choice) { + if (! $choice) { $context['opportunity_choices'] = []; $context['requirements_assessment']['requirements'] = []; + return $context; } $context['selection']['opportunity'] = $choice['opportunity']; $context['fields'] = [...$context['fields'], ...Arr::only($choice['opportunity']['fields'], $specific)]; - foreach (Arr::only($choice['opportunity']['fields'], $specific) as $field => $value) $context['provenance'][$field] = Arr::only($choice['opportunity'], ['key','kind','id','title','version','sha256']); + foreach (Arr::only($choice['opportunity']['fields'], $specific) as $field => $value) { + $context['provenance'][$field] = Arr::only($choice['opportunity'], ['key', 'kind', 'id', 'title', 'version', 'sha256']); + } unset($context['selection']['sector']); - if (!empty($context['fields']['sector_id'])) $context['selection']['sector'] = app(InvestorContext::class)->reference('sector:'.$context['fields']['sector_id']); + if (! empty($context['fields']['sector_id'])) { + $context['selection']['sector'] = app(InvestorContext::class)->reference('sector:'.$context['fields']['sector_id']); + } if ($choice['territory']) { $context['selection']['territory'] = $choice['territory']; $context['fields'] = [...Arr::except($context['fields'], array_keys(InvestmentGeography::FIELDS)), ...$choice['territory']['fields']]; - foreach ($choice['territory']['fields'] as $field => $value) $context['provenance'][$field] = Arr::only($choice['territory'], ['key','kind','id','title','version','sha256']); + foreach ($choice['territory']['fields'] as $field => $value) { + $context['provenance'][$field] = Arr::only($choice['territory'], ['key', 'kind', 'id', 'title', 'version', 'sha256']); + } } else { $context['fields'] = Arr::except($context['fields'], ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location']); } $park = $choice['park'] ?? null; - if ($park) $context['selection']['park'] = $park; + if ($park) { + $context['selection']['park'] = $park; + } $context['fields']['special_zone_id'] = $park['id'] ?? ''; $context['fields']['investment_regime'] = $park['fields']['investment_regime'] ?? 'general'; $context['opportunity_choices'] = [$choice]; - $context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn($r) => $r['opportunity_key'] === $key)); + $context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn ($r) => $r['opportunity_key'] === $key)); $context['consumer_opportunity'] = $choice; - $context['complements'] = array_values(array_filter($context['complements'] ?? [], fn($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '',10),array_column($choice['portfolio_origins'] ?? [],'id'),true))); - $context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices'=>[$choice], 'fields'=>Arr::except($context['fields'],['project_title','project_summary']), 'complements'=>$context['complements']]); + $context['complements'] = array_values(array_filter($context['complements'] ?? [], fn ($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '', 10), array_column($choice['portfolio_origins'] ?? [], 'id'), true))); + $context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices' => [$choice], 'fields' => Arr::except($context['fields'], ['project_title', 'project_summary']), 'complements' => $context['complements']]); + return $context; } + /** + * @param array $payload + * @return array + */ public function bind(User $actor, string $id, array $payload, bool $required = false): array { $case = app(VirtualDesk::class)->case($actor, $id, true); $context = app(InvestorContext::class)->state($case); $scoped = $this->context($context, $payload['context_opportunity'] ?? null); $choice = $scoped['consumer_opportunity'] ?? null; - abort_if($required && count($context['opportunity_choices'] ?? []) > 1 && !$choice, 422, 'Escolha a oportunidade em tratamento antes de submeter.'); - if (!$choice) return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']); + abort_if($required && count($context['opportunity_choices'] ?? []) > 1 && ! $choice, 422, 'Escolha a oportunidade em tratamento antes de submeter.'); + if (! $choice) { + return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']); + } // Canonical server snapshot; client text cannot establish a different origin or territory. $payload['context_opportunity'] = $choice['opportunity']['key']; $payload['context_opportunity_snapshot'] = json_encode(['context_id' => $id, 'revision' => $context['revision'], 'choice' => $choice, 'requirements' => $scoped['requirements_assessment'] ?? null], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + return $payload; } } diff --git a/app/Services/InvestmentContextRequirements.php b/app/Services/InvestmentContextRequirements.php index 155900e3..dd99a40a 100644 --- a/app/Services/InvestmentContextRequirements.php +++ b/app/Services/InvestmentContextRequirements.php @@ -7,6 +7,10 @@ final class InvestmentContextRequirements { public const VERSION = 'context-requirements-20261023'; + /** + * @param array $context + * @return array + */ public function assess(array $context): array { $rows = []; @@ -32,16 +36,21 @@ final class InvestmentContextRequirements ['Actividades efectivas e modalidade', 'Requerimento e anexos da modalidade aplicável; acto quando emitido'], 'Identificar as actividades efectivas antes de seleccionar o procedimento. O nome Agronegócio não escolhe uma licença única.', 'https://www.bau.gov.mz/nossos-servicos/'); foreach (['processing' => ['Licenciamento industrial', 'Decreto 22/2014'], 'trade' => ['Licenciamento comercial', 'Decreto 34/2013']] as $activity => [$title, $basis]) { - if (in_array($activity, $activities, true)) $add($activity, $title, 'conditional', 'S08', 'BAU, IP / entidade licenciadora da modalidade', - ['Caracterização do estabelecimento e actividade', 'Documentos e regime aplicáveis, incluindo simplificado ou mera comunicação quando preenchidas as suas condições'], - $basis.'; regimes 39/2017 e 27/2023 no catálogo oficial BAU. Dimensão e modalidade ainda devem ser especificadas; não se selecciona automaticamente o regime.', 'https://www.bau.gov.mz/legislacao/'); + if (in_array($activity, $activities, true)) { + $add($activity, $title, 'conditional', 'S08', 'BAU, IP / entidade licenciadora da modalidade', + ['Caracterização do estabelecimento e actividade', 'Documentos e regime aplicáveis, incluindo simplificado ou mera comunicação quando preenchidas as suas condições'], + $basis.'; regimes 39/2017 e 27/2023 no catálogo oficial BAU. Dimensão e modalidade ainda devem ser especificadas; não se selecciona automaticamente o regime.', 'https://www.bau.gov.mz/legislacao/'); + } } foreach (['water' => 'Uso de água', 'forestry' => 'Actividade florestal', 'animal' => 'Actividade pecuária', 'transport' => 'Transporte e logística'] as $activity => $title) { - if (in_array($activity, $activities, true)) $add($activity, $title, 'undetermined', 'S08', 'Autoridade sectorial competente a identificar para a modalidade declarada', - ['Modalidade e dimensão da actividade', 'Fonte e documentação sectorial específica a associar ao dossier'], - 'Actividade declarada; a referência do lote não contém o procedimento específico. Não se considera dispensada ou autorizada.', 'https://www.apiex.gov.mz/wp-content/uploads/2024/01/Mozambique-Business-Licensing-Guide.pdf'); + if (in_array($activity, $activities, true)) { + $add($activity, $title, 'undetermined', 'S08', 'Autoridade sectorial competente a identificar para a modalidade declarada', + ['Modalidade e dimensão da actividade', 'Fonte e documentação sectorial específica a associar ao dossier'], + 'Actividade declarada; a referência do lote não contém o procedimento específico. Não se considera dispensada ou autorizada.', 'https://www.apiex.gov.mz/wp-content/uploads/2024/01/Mozambique-Business-Licensing-Guide.pdf'); + } } } + return ['version' => self::VERSION, 'requirements' => $rows, 'fiscal_notice' => 'Localização, corredor, cluster ou projecto âncora não activam benefícios fiscais. O simulador mantém as condições, fonte, período e âmbito próprios; condições ausentes permanecem indeterminadas.']; } diff --git a/app/Services/InvestmentCorridorContext.php b/app/Services/InvestmentCorridorContext.php index 8f2529bc..32d8204d 100644 --- a/app/Services/InvestmentCorridorContext.php +++ b/app/Services/InvestmentCorridorContext.php @@ -10,40 +10,65 @@ final class InvestmentCorridorContext public function key(Opportunity $record): ?string { $name = data_get($record->workflow_context, 'corridor_agribusiness.corridor'); - foreach (config('investment_corridors') as $key => $entry) if ($entry['name'] === $name) return $key; + foreach (config('investment_corridors') as $key => $entry) { + if ($entry['name'] === $name) { + return $key; + } + } + return null; } - /** Reuse Tempo's approved provincial context; never infer a district or master link. */ + /** Reuse Tempo's approved provincial context; never infer a district or master link. + * @return array + */ public function catalogueProvinces(Opportunity $record): array { - if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) return []; - if ($record->primary_territory_id || $record->additionalTerritories()->exists()) return []; + if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) { + return []; + } + if ($record->primary_territory_id || $record->additionalTerritories()->exists()) { + return []; + } $key = $this->key($record); + return $key ? $this->provinces($key) : []; } + /** + * @return array + */ public function provinces(string $key): array { $entry = config('investment_corridors.'.$key); abort_unless(is_array($entry), 422, 'Seleccione um corredor disponível.'); $records = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province') ->whereIn('source_code', $entry['provinces'])->get(['id', 'name', 'parent_id', 'location_type', 'source_code'])->all(); - abort_unless(count($records) === count($entry['provinces']), 503, 'Não foi possível recuperar todas as províncias deste corredor. Tente novamente.'); + if (! (count($records) === count($entry['provinces']))) { + abort(503, 'Não foi possível recuperar todas as províncias deste corredor. Tente novamente.'); + } + return $records; } public function compatible(string $key, ?string $territory): bool { - if (!$territory) return true; + if (! $territory) { + return true; + } $path = app(InvestmentGeography::class)->path($territory); + return $path !== [] && in_array($path[0]->id, array_column($this->provinces($key), 'id'), true); } - /** Both membership in the selected area and documented public zone coverage are required. */ + /** Both membership in the selected area and documented public zone coverage are required. + * @return array + */ public function parks(?string $key, ?string $territory): array { - if (!$key || !$territory) return []; + if (! $key || ! $territory) { + return []; + } abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.'); $selectedPath = array_column(app(InvestmentGeography::class)->path($territory), 'id'); $candidates = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(); @@ -51,18 +76,24 @@ final class InvestmentCorridorContext foreach ($candidates as $zone) { $ids = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->where('validation_status', 'validated') ->whereIn('scope_type', ['legal_scope', 'administrative', 'site']) - ->where(fn($q)=>$q->whereNull('valid_from')->orWhereDate('valid_from','<=',today())) - ->where(fn($q)=>$q->whereNull('valid_until')->orWhereDate('valid_until','>=',today()))->get(['geo_location_id','scope_type'])->all(); - if ($zone->primary_territory_id) $ids[] = (object)['geo_location_id'=>$zone->primary_territory_id,'scope_type'=>'site']; + ->where(fn ($q) => $q->whereNull('valid_from')->orWhereDate('valid_from', '<=', today())) + ->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()))->get(['geo_location_id', 'scope_type'])->all(); + if ($zone->primary_territory_id) { + $ids[] = (object) ['geo_location_id' => $zone->primary_territory_id, 'scope_type' => 'site']; + } foreach ($ids as $link) { - $id=$link->geo_location_id; - if (!$this->compatible($key, $id)) continue; + $id = $link->geo_location_id; + if (! $this->compatible($key, $id)) { + continue; + } $path = array_column(app(InvestmentGeography::class)->path($id), 'id'); - if (in_array($territory, $path, true) || ($link->scope_type==='legal_scope' && in_array($id, $selectedPath, true))) { - $result[] = $zone->only(['id', 'name', 'category', 'version_no']); break; + if (in_array($territory, $path, true) || ($link->scope_type === 'legal_scope' && in_array($id, $selectedPath, true))) { + $result[] = $zone->only(['id', 'name', 'category', 'version_no']); + break; } } } + return $result; } } diff --git a/app/Services/InvestmentGeography.php b/app/Services/InvestmentGeography.php index 6f61f07f..f7084a0d 100644 --- a/app/Services/InvestmentGeography.php +++ b/app/Services/InvestmentGeography.php @@ -12,7 +12,10 @@ final class InvestmentGeography { public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality']; - /** Resolve an old province-name search only when the public register has exactly one match. */ + /** Resolve an old province-name search only when the public register has exactly one match. + * @param array $filters + * @return array + */ public function provinceSearch(array $filters): array { if (empty($filters['province_id']) && is_string($filters['province'] ?? null) && trim($filters['province']) !== '') { diff --git a/app/Services/InvestmentOpportunityChoices.php b/app/Services/InvestmentOpportunityChoices.php index be9f92e6..c16bb331 100644 --- a/app/Services/InvestmentOpportunityChoices.php +++ b/app/Services/InvestmentOpportunityChoices.php @@ -4,47 +4,73 @@ namespace App\Services; use App\Http\Controllers\InvestmentPortfolioController; use App\Models\Opportunity; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; /** Multiple identified references in the existing encrypted investment context. */ final class InvestmentOpportunityChoices { + /** + * @param array $ids + * @return array + */ public function portfolioOrigins(array $ids, int $opportunity): array { $origins = []; foreach (array_unique($ids) as $id) { - $record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail($id); + $record = InvestmentPortfolioController::visible()->addSelect('version_no')->whereKey($id)->firstOrFail(); $kind = null; - if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) $kind = 'corridor'; - foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { - if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) $kind = rtrim($relation, 's'); + if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) { + $kind = 'corridor'; } - abort_unless($kind, 422, 'A associação escolhida não pertence a esta oportunidade.'); + foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { + if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) { + $kind = rtrim($relation, 's'); + } + } + abort_unless($kind !== null, 422, 'A associação escolhida não pertence a esta oportunidade.'); $origins[] = ['id' => $record->id, 'title' => $record->title, 'kind' => $kind, 'href' => '/carteira/'.$record->id, 'version' => $record->version_no, 'sha256' => hash('sha256', json_encode([$record->title, $record->source, $record->content], JSON_THROW_ON_ERROR))]; } + return $origins; } - /** Add explicitly consulted associations without resetting saved geography or deliberate answers. */ + /** Add explicitly consulted associations without resetting saved geography or deliberate answers. + * @param array $entries + * @param array $saved + * @return array + */ public function mergeEntries(array $saved, array $entries): array { foreach ($entries as $entry) { $index = array_search($entry['opportunity']['key'], array_column(array_column($saved, 'opportunity'), 'key'), true); - if ($index === false) { $saved[] = $entry; continue; } + if ($index === false) { + $saved[] = $entry; + + continue; + } $origins = collect([...($saved[$index]['portfolio_origins'] ?? []), ...($entry['portfolio_origins'] ?? [])])->unique('id')->values()->all(); - if ($origins) $saved[$index]['portfolio_origins'] = $origins; + if ($origins) { + $saved[$index]['portfolio_origins'] = $origins; + } } + return $saved; } + /** + * @param array $choices + * @param array $previous + * @return array + */ public function resolve(array $choices, array $previous = [], bool $pending = false): array { $result = []; foreach ($choices as $choice) { $ref = app(InvestorContext::class)->reference($choice['source']); abort_unless($ref['kind'] === 'opportunity', 422, 'Seleccione uma ficha de oportunidade.'); - $record = Opportunity::findOrFail($ref['id']); + $record = Opportunity::whereKey($ref['id'])->firstOrFail(); $old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']); $origins = $this->portfolioOrigins($choice['portfolio_origins'] ?? array_column($old['portfolio_origins'] ?? [], 'id'), (int) $record->id); $corridors = app(InvestmentCorridorContext::class); @@ -52,14 +78,18 @@ final class InvestmentOpportunityChoices $provinces = $corridor ? $corridors->provinces($corridor) : []; $documented = $record->primary_territory_id; $territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null); - if (!$territory && count($provinces) === 1) $territory = 'territory:'.$provinces[0]->id; - if ($corridor && $territory) abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.'); + if (! $territory && count($provinces) === 1) { + $territory = 'territory:'.$provinces[0]->id; + } + if ($corridor && $territory) { + abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.'); + } $previousTerritory = $old['territory']['key'] ?? null; $newAlternative = $documented && $territory !== 'territory:'.$documented && (! $old || ($old['documented_territory_id'] ?? null) !== $documented || $territory !== $previousTerritory); $corridorChanged = $old && ($old['corridor_key'] ?? $corridors->key($record)) !== $corridor; - $incompatibleOld = $corridorChanged && $previousTerritory && !$corridors->compatible($corridor, substr($previousTerritory, 10)); - if (!$pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && !$incompatibleOld))) { + $incompatibleOld = $corridorChanged && $previousTerritory && ! $corridors->compatible($corridor, substr($previousTerritory, 10)); + if (! $pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && ! $incompatibleOld))) { abort_unless(! empty($choice['confirm_location_change']) && mb_strlen(trim($choice['location_reason'] ?? '')) >= 20, 422, 'Uma localização documentada ou já confirmada só muda por escolha explícita, com confirmação e motivo.'); } @@ -69,17 +99,17 @@ final class InvestmentOpportunityChoices $park = $parkKey ? app(InvestorContext::class)->reference($parkKey) : null; if ($park) { $eligible = $corridor ? array_column($corridors->parks($corridor, $location['id'] ?? null), 'id') : []; - if (!in_array($park['id'], $eligible, true)) { - abort_if(!empty($choice['park']), 422, 'A zona ou parque não é compatível com a localização desta oportunidade.'); + if (! in_array($park['id'], $eligible, true)) { + abort_if(! empty($choice['park']), 422, 'A zona ou parque não é compatível com a localização desta oportunidade.'); $park = null; } - abort_unless(!$park || $park['kind'] === 'park', 422); + abort_unless(! $park || $park['kind'] === 'park', 422); } $result[] = ['opportunity' => $ref, 'territory' => $location, ...($origins ? ['portfolio_origins' => $origins] : []), 'park' => $park, 'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null, - 'allowed_provinces' => array_map(fn($p)=>(array)$p, $provinces), - 'territory_path' => $location ? array_map(fn ($node) => \Illuminate\Support\Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [], + 'allowed_provinces' => array_map(fn ($p) => (array) $p, $provinces), + 'territory_path' => $location ? array_map(fn ($node) => Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [], 'activities' => $choice['activities'] ?? ($old['activities'] ?? []), 'documented_territory_id' => $documented, 'location_reason' => $choice['location_reason'] ?? '', 'location_state' => $location ? ($documented && $territory === 'territory:'.$documented ? 'documented' : 'investor_selected') : 'undetermined', @@ -88,6 +118,7 @@ final class InvestmentOpportunityChoices 'source' => data_get($record->workflow_context, 'corridor_agribusiness.source'), 'source_sha256' => data_get($record->workflow_context, 'corridor_agribusiness.source_sha256')]; } + return $result; } } diff --git a/app/Services/InvestmentZoneIntegralDetails.php b/app/Services/InvestmentZoneIntegralDetails.php index b4721826..15886e66 100644 --- a/app/Services/InvestmentZoneIntegralDetails.php +++ b/app/Services/InvestmentZoneIntegralDetails.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\InvestmentZone; +use App\Support\StructuredData; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -11,6 +12,7 @@ use Illuminate\Support\Str; class InvestmentZoneIntegralDetails { public const GROUPS = ['titles', 'phases', 'plots', 'occupants', 'observations', 'chains']; + public const EXTRA = [ 'titles' => ['kind', 'identifier', 'valid_from', 'valid_until'], 'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'], @@ -19,79 +21,179 @@ class InvestmentZoneIntegralDetails 'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'], 'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'], ]; + public const COMMON = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', 'disclosure', 'private_note', 'support_document_ids']; + /** + * @param array $before + * @param array $input + * @return array + */ public function merge(InvestmentZone $zone, array $before, array $input): array { - abort_if(array_diff(array_keys($input), ['schema_version', 'remove_ids', ...self::GROUPS]), 422, 'Campo da ficha integral desconhecido.'); + abort_if(array_diff(array_keys($input), ['schema_version', 'remove_ids', ...self::GROUPS]) !== [], 422, 'Campo da ficha integral desconhecido.'); $rules = ['schema_version' => 'sometimes|in:1', 'remove_ids' => 'sometimes|array:'.implode(',', self::GROUPS)]; foreach (self::GROUPS as $group) { $rules[$group] = 'sometimes|array|max:200'; $rules[$group.'.*'] = 'array:'.implode(',', [...self::COMMON, ...self::EXTRA[$group]]); - foreach (['id'=>'nullable|uuid','label'=>'required|string|max:300','label_en'=>'nullable|string|max:300','description'=>'nullable|string|max:3000','description_en'=>'nullable|string|max:3000','knowledge'=>'required|in:unknown,documented,disputed','source_reference'=>'nullable|string|max:2000','source_date'=>'nullable|date_format:Y-m-d','scope'=>'nullable|string|max:500','disclosure'=>'required|boolean','private_note'=>'nullable|string|max:4000','support_document_ids'=>'sometimes|array|max:20','support_document_ids.*'=>'uuid'] as $key=>$rule) $rules[$group.'.*.'.$key]=$rule; - foreach (self::EXTRA[$group] as $key) $rules[$group.'.*.'.$key]=match ($key) { - 'organization_id','phase_id','plot_id','zone_link_id','geometry_id'=>'nullable|uuid', - 'area'=>'nullable|numeric|min:0|max:999999999999999', - 'value'=>'nullable|numeric|between:-999999999999999,999999999999999', - 'starts_at','ends_at','valid_from','valid_until','period_start','period_end'=>'nullable|date_format:Y-m-d', - 'expansion'=>'nullable|boolean', - default=>'nullable|string|max:2000', - }; - $rules['remove_ids.'.$group]='sometimes|array|max:200';$rules['remove_ids.'.$group.'.*']='uuid'; - } - $rules['plots.*.area_unit']='nullable|in:ha,m2'; - $rules['plots.*.status']='nullable|in:unknown,planned,available_declared,reserved,occupied,construction,inactive'; - $rules['phases.*.status']='nullable|in:unknown,planned,construction,operational,suspended,closed'; - $rules['occupants.*.status']='nullable|in:unknown,approved,construction,installed,inactive'; - $rules['titles.*.kind']='nullable|in:creation,land_use,licence,special_regime,other'; - $rules['chains.*.kind']='nullable|in:anchor,supplier,unmet_need,other'; - $rules['observations.*.indicator']='required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other'; - $data=Validator::make($input,$rules)->validate();$result=$before; - foreach(self::GROUPS as$group){ - $rows=collect($before[$group]??[])->keyBy('id')->all();$remove=$data['remove_ids'][$group]??[];$seen=[]; - foreach($data[$group]??[]as$item){ - $id=$item['id']??(string)Str::uuid();abort_if(in_array($id,$seen,true)||in_array($id,$remove,true),422,'Identificador repetido ou removido na mesma operação.');$seen[]=$id; - $item=[...($rows[$id]??[]),...$item,'id'=>$id]; - if($item['knowledge']!=='unknown')abort_unless(trim($item['source_reference']??'')!=='',422,'Indique a fonte documental ou preserve como desconhecido.'); - if($item['knowledge']==='unknown')abort_if(isset($item['value'])||isset($item['area']),422,'Quantidade desconhecida deve permanecer vazia, não zero.'); - if($item['knowledge']==='unknown'&&!empty($item['status']))abort_unless($item['status']==='unknown',422,'Situação operacional declarada exige fonte ou indicação de divergência.'); - foreach([['starts_at','ends_at'],['valid_from','valid_until'],['period_start','period_end']]as[$a,$b])if(isset($item[$a],$item[$b]))abort_if($item[$b]<$item[$a],422,'Período final anterior ao início.'); - if($group==='plots'&&isset($item['area']))abort_unless(!empty($item['area_unit'])&&!empty($item['scope']),422,'Área declarada exige unidade e âmbito.'); - if($group==='plots'&&($item['status']??'')==='available_declared')abort_unless($item['knowledge']==='documented'&&!empty($item['source_date']),422,'Disponibilidade declarada exige fonte e data; não é disponibilidade em tempo real.'); - if($group==='observations'&&isset($item['value']))abort_unless(!empty($item['unit'])&&!empty($item['period_start'])&&!empty($item['period_end'])&&!empty($item['scope'])&&!empty($item['population']),422,'Indicador numérico exige unidade, período, âmbito e base/população.'); - if(!empty($item['organization_id']))abort_unless(DB::table('organizations')->where('id',$item['organization_id'])->whereNull('deleted_at')->exists(),422,'Reutilize uma entidade existente.'); - if(!empty($item['geometry_id']))abort_unless(DB::table('investment_zone_geometries')->where('zone_id',$zone->id)->where('geometry_id',$item['geometry_id'])->exists(),422,'Geometria deve estar associada a este parque; limite administrativo não é perímetro.'); - if(!empty($item['zone_link_id']))abort_unless(DB::table('investment_zone_links')->where('zone_id',$zone->id)->where('id',$item['zone_link_id'])->exists(),422,'Relação económica deve pertencer a este parque.'); - foreach($item['support_document_ids']??[]as$doc)abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d','d.id','=','a.document_id')->where('a.zone_id',$zone->id)->where('d.id',$doc)->whereNull('d.deleted_at')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256')->exists(),422,'Suporte deve estar consultável e associado a este parque.'); - $rows[$id]=$item; + foreach (['id' => 'nullable|uuid', 'label' => 'required|string|max:300', 'label_en' => 'nullable|string|max:300', 'description' => 'nullable|string|max:3000', 'description_en' => 'nullable|string|max:3000', 'knowledge' => 'required|in:unknown,documented,disputed', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500', 'disclosure' => 'required|boolean', 'private_note' => 'nullable|string|max:4000', 'support_document_ids' => 'sometimes|array|max:20', 'support_document_ids.*' => 'uuid'] as $key => $rule) { + $rules[$group.'.*.'.$key] = $rule; } - foreach($remove as$id){abort_unless(isset($rows[$id]),422,'O detalhe a remover não pertence ao parque.');unset($rows[$id]);} - abort_if(count($rows)>200,422,'Limite de detalhes excedido.');$result[$group]=array_values($rows); + foreach (self::EXTRA[$group] as $key) { + $rules[$group.'.*.'.$key] = match ($key) { + 'organization_id','phase_id','plot_id','zone_link_id','geometry_id' => 'nullable|uuid', + 'area' => 'nullable|numeric|min:0|max:999999999999999', + 'value' => 'nullable|numeric|between:-999999999999999,999999999999999', + 'starts_at','ends_at','valid_from','valid_until','period_start','period_end' => 'nullable|date_format:Y-m-d', + 'expansion' => 'nullable|boolean', + default => 'nullable|string|max:2000', + }; + } + $rules['remove_ids.'.$group] = 'sometimes|array|max:200'; + $rules['remove_ids.'.$group.'.*'] = 'uuid'; + } + $rules['plots.*.area_unit'] = 'nullable|in:ha,m2'; + $rules['plots.*.status'] = 'nullable|in:unknown,planned,available_declared,reserved,occupied,construction,inactive'; + $rules['phases.*.status'] = 'nullable|in:unknown,planned,construction,operational,suspended,closed'; + $rules['occupants.*.status'] = 'nullable|in:unknown,approved,construction,installed,inactive'; + $rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other'; + $rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other'; + $rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other'; + $data = Validator::make($input, $rules)->validate(); + $result = $before; + foreach (self::GROUPS as $group) { + $rows = collect(StructuredData::collectionItems($before[$group] ?? []))->keyBy('id')->all(); + $remove = $data['remove_ids'][$group] ?? []; + $seen = []; + foreach ($data[$group] ?? [] as $item) { + $id = $item['id'] ?? (string) Str::uuid(); + abort_if(in_array($id, $seen, true) || in_array($id, $remove, true), 422, 'Identificador repetido ou removido na mesma operação.'); + $seen[] = $id; + $item = [...($rows[$id] ?? []), ...$item, 'id' => $id]; + if ($item['knowledge'] !== 'unknown') { + abort_unless(trim($item['source_reference'] ?? '') !== '', 422, 'Indique a fonte documental ou preserve como desconhecido.'); + } + if ($item['knowledge'] === 'unknown') { + abort_if(isset($item['value']) || isset($item['area']), 422, 'Quantidade desconhecida deve permanecer vazia, não zero.'); + } + if ($item['knowledge'] === 'unknown' && ! empty($item['status'])) { + abort_unless($item['status'] === 'unknown', 422, 'Situação operacional declarada exige fonte ou indicação de divergência.'); + } + foreach ([['starts_at', 'ends_at'], ['valid_from', 'valid_until'], ['period_start', 'period_end']] as [$a,$b]) { + if (isset($item[$a],$item[$b])) { + abort_if($item[$b] < $item[$a], 422, 'Período final anterior ao início.'); + } + } + if ($group === 'plots' && isset($item['area'])) { + abort_unless(! empty($item['area_unit']) && ! empty($item['scope']), 422, 'Área declarada exige unidade e âmbito.'); + } + if ($group === 'plots' && ($item['status'] ?? '') === 'available_declared') { + abort_unless($item['knowledge'] === 'documented' && ! empty($item['source_date']), 422, 'Disponibilidade declarada exige fonte e data; não é disponibilidade em tempo real.'); + } + if ($group === 'observations' && isset($item['value'])) { + abort_unless(! empty($item['unit']) && ! empty($item['period_start']) && ! empty($item['period_end']) && ! empty($item['scope']) && ! empty($item['population']), 422, 'Indicador numérico exige unidade, período, âmbito e base/população.'); + } + if (! empty($item['organization_id'])) { + abort_unless(DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->exists(), 422, 'Reutilize uma entidade existente.'); + } + if (! empty($item['geometry_id'])) { + abort_unless(DB::table('investment_zone_geometries')->where('zone_id', $zone->id)->where('geometry_id', $item['geometry_id'])->exists(), 422, 'Geometria deve estar associada a este parque; limite administrativo não é perímetro.'); + } + if (! empty($item['zone_link_id'])) { + abort_unless(DB::table('investment_zone_links')->where('zone_id', $zone->id)->where('id', $item['zone_link_id'])->exists(), 422, 'Relação económica deve pertencer a este parque.'); + } + foreach ($item['support_document_ids'] ?? [] as $doc) { + abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')->where('a.zone_id', $zone->id)->where('d.id', $doc)->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->exists(), 422, 'Suporte deve estar consultável e associado a este parque.'); + } + $rows[$id] = $item; + } + foreach ($remove as $id) { + abort_unless(isset($rows[$id]), 422, 'O detalhe a remover não pertence ao parque.'); + unset($rows[$id]); + } + abort_if(count($rows) > 200, 422, 'Limite de detalhes excedido.'); + $result[$group] = array_values($rows); } // References resolve only inside this park, including new items in the same transaction. - foreach(self::GROUPS as$group)foreach($result[$group]as$item){ - foreach(['phase_id'=>'phases','plot_id'=>'plots']as$key=>$target)if(!empty($item[$key]))abort_unless(in_array($item[$key],array_column($result[$target],'id'),true),422,'Referência interna inexistente ou de outro parque.'); - if(!empty($item['phase_id'])&&!empty($item['plot_id'])){$plot=collect($result['plots'])->firstWhere('id',$item['plot_id']);abort_if(!empty($plot['phase_id'])&&$plot['phase_id']!==$item['phase_id'],422,'Lote e fase não correspondem.');} + foreach (self::GROUPS as $group) { + foreach ($result[$group] as $item) { + foreach (['phase_id' => 'phases', 'plot_id' => 'plots'] as $key => $target) { + if (! empty($item[$key])) { + abort_unless(in_array($item[$key], array_column($result[$target], 'id'), true), 422, 'Referência interna inexistente ou de outro parque.'); + } + } + if (! empty($item['phase_id']) && ! empty($item['plot_id'])) { + $plot = collect($result['plots'])->firstWhere('id', $item['plot_id']); + abort_if(! empty($plot['phase_id']) && $plot['phase_id'] !== $item['phase_id'], 422, 'Lote e fase não correspondem.'); + } + } } - foreach(['phases','plots']as$group){$codes=[];foreach($result[$group]as$item)if(trim($item['code']??'')!==''){$code=mb_strtolower(trim($item['code']));abort_if(in_array($code,$codes,true),422,'Código interno repetido no parque.');$codes[]=$code;}} - $keys=[];foreach($result['occupants']as$item){$key=implode('|',[$item['organization_id']??$item['documentary_name']??$item['id'],$item['plot_id']??'',$item['valid_from']??'',$item['valid_until']??'']);abort_if(in_array($key,$keys,true),422,'Ocupante/período já registado; reutilize o detalhe.');$keys[]=$key;} - $keys=[];foreach($result['observations']as$item){if(!isset($item['period_start'],$item['period_end']))continue;$key=implode('|',[$item['indicator'],$item['period_start'],$item['period_end'],$item['scope']??'',$item['population']??'',$item['unit']??'',$item['methodology']??'',$item['source_reference']??'',$item['source_date']??'',$item['phase_id']??'',$item['plot_id']??'']);abort_if(in_array($key,$keys,true),422,'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');$keys[]=$key;} - return [...$result,'schema_version'=>1]; + foreach (['phases', 'plots'] as $group) { + $codes = []; + foreach ($result[$group] as $item) { + if (trim($item['code'] ?? '') !== '') { + $code = mb_strtolower(trim($item['code'])); + abort_if(in_array($code, $codes, true), 422, 'Código interno repetido no parque.'); + $codes[] = $code; + } + } + } + $keys = []; + foreach ($result['occupants'] as $item) { + $key = implode('|', [$item['organization_id'] ?? $item['documentary_name'] ?? $item['id'], $item['plot_id'] ?? '', $item['valid_from'] ?? '', $item['valid_until'] ?? '']); + abort_if(in_array($key, $keys, true), 422, 'Ocupante/período já registado; reutilize o detalhe.'); + $keys[] = $key; + } + $keys = []; + foreach ($result['observations'] as $item) { + if (! isset($item['period_start'],$item['period_end'])) { + continue; + }$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']); + abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.'); + $keys[] = $key; + } + + return [...$result, 'schema_version' => 1]; } + /** + * @param array $fields + * @return array + */ public function publicData(array $fields): array { - $public=['schema_version'=>1]; - foreach(self::GROUPS as$group){$public[$group]=[];foreach($fields[$group]??[]as$item){ - if(!($item['disclosure']??false))continue; - $keys=['id','label','label_en','description','description_en','knowledge','source_reference','source_date','scope',...self::EXTRA[$group]]; - $visible=array_intersect_key($item,array_flip($keys));unset($visible['organization_id'],$visible['documentary_name'],$visible['geometry_id'],$visible['zone_link_id']); - if(in_array($group,['occupants','chains'],true)&&!empty($item['organization_id'])){$org=DB::table('organizations')->where('id',$item['organization_id'])->whereNull('deleted_at')->where('data_classification','public')->first();abort_unless($org&&$item['knowledge']==='documented',422,'Divulgar entidade exige cadastro público e fonte; a menção preliminar permanece privada.');$visible['organization_name']=$org->name;} - if($group==='occupants')abort_unless(!empty($visible['organization_name']),422,'Ocupante por identificar permanece privado.'); - $public[$group][]=$visible; - }} + $public = ['schema_version' => 1]; + foreach (self::GROUPS as $group) { + $public[$group] = []; + foreach ($fields[$group] ?? [] as $item) { + if (! ($item['disclosure'] ?? false)) { + continue; + } + $keys = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', ...self::EXTRA[$group]]; + $visible = array_intersect_key($item, array_flip($keys)); + unset($visible['organization_id'],$visible['documentary_name'],$visible['geometry_id'],$visible['zone_link_id']); + if (in_array($group, ['occupants', 'chains'], true) && ! empty($item['organization_id'])) { + $org = DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->where('data_classification', 'public')->first(); + abort_unless($org && $item['knowledge'] === 'documented', 422, 'Divulgar entidade exige cadastro público e fonte; a menção preliminar permanece privada.'); + $visible['organization_name'] = $org->name; + } + if ($group === 'occupants') { + abort_unless(! empty($visible['organization_name']), 422, 'Ocupante por identificar permanece privado.'); + } + $public[$group][] = $visible; + } + } // A disclosed item must not leak the identifier of an undisclosed phase or plot. - foreach(self::GROUPS as$group)foreach($public[$group]as&$item)foreach(['phase_id'=>'phases','plot_id'=>'plots']as$key=>$target)if(!empty($item[$key])&&!in_array($item[$key],array_column($public[$target],'id'),true))unset($item[$key]);unset($item); + foreach (self::GROUPS as $group) { + foreach ($public[$group] as &$item) { + foreach (['phase_id' => 'phases', 'plot_id' => 'plots'] as $key => $target) { + if (! empty($item[$key]) && ! in_array($item[$key], array_column($public[$target], 'id'), true)) { + unset($item[$key]); + } + } + } + }unset($item); + return $public; } } diff --git a/app/Services/InvestmentZoneOperationalDetails.php b/app/Services/InvestmentZoneOperationalDetails.php index e794a7ec..b1ab6546 100644 --- a/app/Services/InvestmentZoneOperationalDetails.php +++ b/app/Services/InvestmentZoneOperationalDetails.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\InvestmentZone; +use App\Support\StructuredData; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -14,119 +15,188 @@ use Illuminate\Validation\ValidationException; class InvestmentZoneOperationalDetails { public const MIGRATION = '2026_10_16_140000_add_investment_zone_operational_details'; + public const GROUPS = ['responsibilities', 'conditions', 'risks']; - public function installed(): bool { return Schema::hasColumn('investment_zones', 'operational_details'); } + public function installed(): bool + { + return Schema::hasColumn('investment_zones', 'operational_details'); + } + /** + * @return array|null + */ public function stored(InvestmentZone $zone): ?array { $raw = $zone->getRawOriginal('operational_details'); + return $raw ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null; } + /** + * @return array + */ public function recover(InvestmentZone $zone): array { $stored = $this->stored($zone); - if (!$stored) { return ['schema_version'=>1, 'responsibilities'=>[], 'conditions'=>[], 'risks'=>[], 'review'=>[]]; } + if (! $stored) { + return ['schema_version' => 1, 'responsibilities' => [], 'conditions' => [], 'risks' => [], 'review' => []]; + } try { - if (($stored['schema_version'] ?? null) !== 1) { throw new \RuntimeException('Unsupported supplement'); } + if (($stored['schema_version'] ?? null) !== 1) { + throw new \RuntimeException('Unsupported supplement'); + } + return json_decode(Crypt::decryptString($stored['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { - throw ValidationException::withMessages(['operational_details'=>'Não foi possível recuperar os detalhes privados. Os dados foram preservados; contacte o suporte.']); + throw ValidationException::withMessages(['operational_details' => 'Não foi possível recuperar os detalhes privados. Os dados foram preservados; contacte o suporte.']); } } + /** + * @return array + */ public function publicData(InvestmentZone $zone): array { return $this->stored($zone)['public'] ?? []; } - /** Merge only supplied keys. An absent group/item is preserved; removal requires explicit IDs. */ + /** Merge only supplied keys. An absent group/item is preserved; removal requires explicit IDs. + * @param array $before + * @param array $input + * @return array + */ public function merge(InvestmentZone $zone, array $before, array $input, ?string $operatorId): array { - abort_unless($zone->category==='industrial_park',422,'O complemento L1 aplica-se à ficha de parques.'); - abort_unless($this->installed(),503,'Detalhes L1 ainda não instalados. A ficha pública foi preservada.'); - $rules=['integral'=>'sometimes|array','schema_version'=>'sometimes|in:1','responsibilities'=>'sometimes|array|max:40','conditions'=>'sometimes|array|max:60','risks'=>'sometimes|array|max:40', - 'remove_ids'=>'sometimes|array:responsibilities,conditions,risks','review'=>'sometimes|array:source_reference,source_date,scope,confidence,last_reviewed_at,next_review_at,private_note', - 'review.confidence'=>'nullable|in:unknown,reported,documented,disputed','review.last_reviewed_at'=>'nullable|date_format:Y-m-d','review.next_review_at'=>'nullable|date_format:Y-m-d','review.private_note'=>'nullable|string|max:4000']; - $common='id,label,knowledge,disclosure,source_reference,source_date,scope,private_note,support_document_ids'; - foreach(self::GROUPS as $g){ - $extra=$g==='responsibilities'?',role,organization_id,documentary_name,private_contact,public_contact,declared_nature,management_model':($g==='conditions'?',value,currency,unit,period,valid_from,valid_until,modality':',risk_type,mitigation,responsible_role,channel,channel_owner'); - $rules[$g.'.*']=['array:'.$common.$extra]; - foreach(['id'=>'nullable|uuid','label'=>'required|string|max:300','knowledge'=>'required|in:unknown,documented,disputed','disclosure'=>'required|boolean', - 'source_reference'=>'nullable|string|max:2000','source_date'=>'nullable|date_format:Y-m-d','scope'=>'nullable|string|max:500','private_note'=>'nullable|string|max:4000', - 'support_document_ids'=>'sometimes|array|max:10','support_document_ids.*'=>'uuid'] as $k=>$r){$rules[$g.'.*.'.$k]=$r;} - $rules['remove_ids.'.$g]='sometimes|array|max:60';$rules['remove_ids.'.$g.'.*']='uuid'; + abort_unless($zone->category === 'industrial_park', 422, 'O complemento L1 aplica-se à ficha de parques.'); + abort_unless($this->installed(), 503, 'Detalhes L1 ainda não instalados. A ficha pública foi preservada.'); + $rules = ['integral' => 'sometimes|array', 'schema_version' => 'sometimes|in:1', 'responsibilities' => 'sometimes|array|max:40', 'conditions' => 'sometimes|array|max:60', 'risks' => 'sometimes|array|max:40', + 'remove_ids' => 'sometimes|array:responsibilities,conditions,risks', 'review' => 'sometimes|array:source_reference,source_date,scope,confidence,last_reviewed_at,next_review_at,private_note', + 'review.confidence' => 'nullable|in:unknown,reported,documented,disputed', 'review.last_reviewed_at' => 'nullable|date_format:Y-m-d', 'review.next_review_at' => 'nullable|date_format:Y-m-d', 'review.private_note' => 'nullable|string|max:4000']; + $common = 'id,label,knowledge,disclosure,source_reference,source_date,scope,private_note,support_document_ids'; + foreach (self::GROUPS as $g) { + $extra = $g === 'responsibilities' ? ',role,organization_id,documentary_name,private_contact,public_contact,declared_nature,management_model' : ($g === 'conditions' ? ',value,currency,unit,period,valid_from,valid_until,modality' : ',risk_type,mitigation,responsible_role,channel,channel_owner'); + $rules[$g.'.*'] = ['array:'.$common.$extra]; + foreach (['id' => 'nullable|uuid', 'label' => 'required|string|max:300', 'knowledge' => 'required|in:unknown,documented,disputed', 'disclosure' => 'required|boolean', + 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500', 'private_note' => 'nullable|string|max:4000', + 'support_document_ids' => 'sometimes|array|max:10', 'support_document_ids.*' => 'uuid'] as $k => $r) { + $rules[$g.'.*.'.$k] = $r; + } + $rules['remove_ids.'.$g] = 'sometimes|array|max:60'; + $rules['remove_ids.'.$g.'.*'] = 'uuid'; } - foreach(['source_reference'=>'nullable|string|max:2000','source_date'=>'nullable|date_format:Y-m-d','scope'=>'nullable|string|max:500']as$k=>$r){$rules['review.'.$k]=$r;} - foreach(['role'=>'required|in:owner,promoter,operator,manager','organization_id'=>'nullable|uuid','documentary_name'=>'nullable|string|max:300','private_contact'=>'nullable|string|max:1000','public_contact'=>'nullable|string|max:1000','declared_nature'=>'nullable|string|max:300','management_model'=>'nullable|string|max:500']as$k=>$r){$rules['responsibilities.*.'.$k]=$r;} - foreach(['value'=>'nullable|numeric|min:0','currency'=>'nullable|in:MZN,USD,EUR,ZAR','unit'=>'nullable|string|max:80','period'=>'nullable|string|max:80','valid_from'=>'nullable|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d','modality'=>'nullable|string|max:300']as$k=>$r){$rules['conditions.*.'.$k]=$r;} - foreach(['risk_type'=>'nullable|string|max:300','mitigation'=>'nullable|string|max:2000','responsible_role'=>'nullable|in:owner,promoter,operator,manager,unknown','channel'=>'nullable|string|max:1000','channel_owner'=>'nullable|in:park,tempo,unknown']as$k=>$r){$rules['risks.*.'.$k]=$r;} - abort_if(array_diff(array_keys($input),['schema_version',...self::GROUPS,'review','remove_ids','integral']),422,'Campo L1 não reconhecido.'); - $data=Validator::make($input,$rules)->validate();$result=$before; - foreach(self::GROUPS as $g){ - $rows=collect($before[$g] ?? [])->keyBy('id')->all();$removals=$data['remove_ids'][$g] ?? []; $seen=[]; - foreach($data[$g] ?? [] as $item){ - $id=$item['id'] ?? (string)Str::uuid(); - abort_if(in_array($id,$seen,true)||in_array($id,$removals,true),422,'Identificador L1 repetido ou removido na mesma operação.');$seen[]=$id; - $item=[...($rows[$id] ?? []),...$item,'id'=>$id]; - if($item['knowledge']==='unknown') { abort_if(isset($item['value']),422,'Valor desconhecido deve permanecer vazio, nunca zero por defeito.'); } - else { abort_unless(trim($item['source_reference'] ?? '')!=='',422,'Identifique a fonte da informação documentada ou divergente.'); } - if($g==='conditions' && isset($item['value'])){ - abort_unless(trim($item['currency'] ?? '')!=='' && trim($item['unit'] ?? '')!=='' && trim($item['scope'] ?? '')!=='',422,'Uma tarifa numérica exige moeda, unidade/base e âmbito; pode manter o valor desconhecido.'); + foreach (['source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500'] as $k => $r) { + $rules['review.'.$k] = $r; + } + foreach (['role' => 'required|in:owner,promoter,operator,manager', 'organization_id' => 'nullable|uuid', 'documentary_name' => 'nullable|string|max:300', 'private_contact' => 'nullable|string|max:1000', 'public_contact' => 'nullable|string|max:1000', 'declared_nature' => 'nullable|string|max:300', 'management_model' => 'nullable|string|max:500'] as $k => $r) { + $rules['responsibilities.*.'.$k] = $r; + } + foreach (['value' => 'nullable|numeric|min:0', 'currency' => 'nullable|in:MZN,USD,EUR,ZAR', 'unit' => 'nullable|string|max:80', 'period' => 'nullable|string|max:80', 'valid_from' => 'nullable|date_format:Y-m-d', 'valid_until' => 'nullable|date_format:Y-m-d', 'modality' => 'nullable|string|max:300'] as $k => $r) { + $rules['conditions.*.'.$k] = $r; + } + foreach (['risk_type' => 'nullable|string|max:300', 'mitigation' => 'nullable|string|max:2000', 'responsible_role' => 'nullable|in:owner,promoter,operator,manager,unknown', 'channel' => 'nullable|string|max:1000', 'channel_owner' => 'nullable|in:park,tempo,unknown'] as $k => $r) { + $rules['risks.*.'.$k] = $r; + } + abort_if(array_diff(array_keys($input), ['schema_version', ...self::GROUPS, 'review', 'remove_ids', 'integral']) !== [], 422, 'Campo L1 não reconhecido.'); + $data = Validator::make($input, $rules)->validate(); + $result = $before; + foreach (self::GROUPS as $g) { + $rows = collect(StructuredData::collectionItems($before[$g] ?? []))->keyBy('id')->all(); + $removals = $data['remove_ids'][$g] ?? []; + $seen = []; + foreach ($data[$g] ?? [] as $item) { + $id = $item['id'] ?? (string) Str::uuid(); + abort_if(in_array($id, $seen, true) || in_array($id, $removals, true), 422, 'Identificador L1 repetido ou removido na mesma operação.'); + $seen[] = $id; + $item = [...($rows[$id] ?? []), ...$item, 'id' => $id]; + if ($item['knowledge'] === 'unknown') { + abort_if(isset($item['value']), 422, 'Valor desconhecido deve permanecer vazio, nunca zero por defeito.'); + } else { + abort_unless(trim($item['source_reference'] ?? '') !== '', 422, 'Identifique a fonte da informação documentada ou divergente.'); } - if($g==='conditions' && isset($item['valid_from'],$item['valid_until'])){ - abort_if($item['valid_until']<$item['valid_from'],422,'O fim da vigência é anterior ao início; preserve datas desconhecidas como desconhecidas.'); + if ($g === 'conditions' && isset($item['value'])) { + abort_unless(trim($item['currency'] ?? '') !== '' && trim($item['unit'] ?? '') !== '' && trim($item['scope'] ?? '') !== '', 422, 'Uma tarifa numérica exige moeda, unidade/base e âmbito; pode manter o valor desconhecido.'); } - if($g==='risks' && !empty($item['channel']) && ($item['channel_owner'] ?? 'unknown')==='park'){ - abort_unless($item['knowledge']==='documented' && trim($item['source_reference'] ?? '')!=='',422,'O canal próprio do parque exige fonte; pode manter o canal desconhecido ou usar o apoio da Tempo identificado como tal.'); + if ($g === 'conditions' && isset($item['valid_from'],$item['valid_until'])) { + abort_if($item['valid_until'] < $item['valid_from'], 422, 'O fim da vigência é anterior ao início; preserve datas desconhecidas como desconhecidas.'); } - if($g==='responsibilities'){ - $org=$item['organization_id'] ?? null; - if($org){abort_unless(DB::table('organizations')->where('id',$org)->whereNull('deleted_at')->exists(),422,'Reutilize uma entidade existente ou mantenha a menção documental preliminar.');} - if($item['role']==='operator' && $org){ - $operator=$operatorId; - abort_unless($operator===$org,422,'O operador é a associação canónica da ficha; não crie outro no suplemento.'); + if ($g === 'risks' && ! empty($item['channel']) && ($item['channel_owner'] ?? 'unknown') === 'park') { + abort_unless($item['knowledge'] === 'documented', 422, 'O canal próprio do parque exige fonte; pode manter o canal desconhecido ou usar o apoio da Tempo identificado como tal.'); + } + if ($g === 'responsibilities') { + $org = $item['organization_id'] ?? null; + if ($org) { + abort_unless(DB::table('organizations')->where('id', $org)->whereNull('deleted_at')->exists(), 422, 'Reutilize uma entidade existente ou mantenha a menção documental preliminar.'); + } + if ($item['role'] === 'operator' && $org) { + $operator = $operatorId; + abort_unless($operator === $org, 422, 'O operador é a associação canónica da ficha; não crie outro no suplemento.'); } } - foreach($item['support_document_ids'] ?? []as$document){ - abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d','d.id','=','a.document_id')->where('a.zone_id',$zone->id)->where('d.id',$document) - ->whereNull('d.deleted_at')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256')->exists(),422,'Selecione suporte consultável já associado à ficha; a publicação do anexo é independente.'); + foreach ($item['support_document_ids'] ?? [] as $document) { + abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')->where('a.zone_id', $zone->id)->where('d.id', $document) + ->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->exists(), 422, 'Selecione suporte consultável já associado à ficha; a publicação do anexo é independente.'); } - $rows[$id]=$item; + $rows[$id] = $item; } - foreach($removals as$id){abort_unless(isset($rows[$id]),422,'Detalhe a remover não pertence à ficha.');unset($rows[$id]);} - abort_if(count($rows)>($g==='conditions'?60:40),422,'Limite de detalhes por grupo excedido.'); - $result[$g]=array_values($rows); + foreach ($removals as $id) { + abort_unless(isset($rows[$id]), 422, 'Detalhe a remover não pertence à ficha.'); + unset($rows[$id]); + } + abort_if(count($rows) > ($g === 'conditions' ? 60 : 40), 422, 'Limite de detalhes por grupo excedido.'); + $result[$g] = array_values($rows); } - $pairs=[];foreach($result['responsibilities'] as$item){$key=$item['role'].'|'.($item['organization_id'] ?? $item['documentary_name'] ?? $item['id']);abort_if(in_array($key,$pairs,true),422,'A mesma entidade/função já está registada; reutilize o detalhe existente.');$pairs[]=$key;} - if(array_key_exists('review',$data)){$result['review']=[...($before['review'] ?? []),...$data['review']];} - if(array_key_exists('integral',$data)){$result['integral']=app(InvestmentZoneIntegralDetails::class)->merge($zone,$before['integral'] ?? [],$data['integral']);} - $result['schema_version']=1; + $pairs = []; + foreach ($result['responsibilities'] as $item) { + $key = $item['role'].'|'.($item['organization_id'] ?? $item['documentary_name'] ?? $item['id']); + abort_if(in_array($key, $pairs, true), 422, 'A mesma entidade/função já está registada; reutilize o detalhe existente.'); + $pairs[] = $key; + } + if (array_key_exists('review', $data)) { + $result['review'] = [...($before['review'] ?? []), ...$data['review']]; + } + if (array_key_exists('integral', $data)) { + $result['integral'] = app(InvestmentZoneIntegralDetails::class)->merge($zone, $before['integral'] ?? [], $data['integral']); + } + $result['schema_version'] = 1; + return $result; } + /** + * @param array $fields + * @return array + */ public function pack(array $fields): array { - $public=[]; - foreach(self::GROUPS as$g){ - $public[$g]=[]; - foreach($fields[$g] ?? []as$item){ - if(!($item['disclosure'] ?? false)){continue;} - $keys=['label','knowledge','source_reference','source_date','scope']; - if($g==='conditions'){$keys=[...$keys,'value','currency','unit','period','valid_from','valid_until','modality'];} - if($g==='risks'){$keys=[...$keys,'risk_type','mitigation','responsible_role','channel','channel_owner'];} - $visible=array_intersect_key($item,array_flip($keys)); - if($g==='responsibilities'){ - $org=empty($item['organization_id'])?null:DB::table('organizations')->where('id',$item['organization_id'])->whereNull('deleted_at')->where('data_classification','public')->first(); - abort_unless($org && $item['knowledge']==='documented',422,'Divulgação de função exige entidade pública existente e fundamento; menção preliminar continua privada.'); - $visible=[...$visible,'role'=>$item['role'],'organization_name'=>$org->name,'public_contact'=>$item['public_contact'] ?? null,'declared_nature'=>$item['declared_nature'] ?? null,'management_model'=>$item['management_model'] ?? null]; + $public = []; + foreach (self::GROUPS as $g) { + $public[$g] = []; + foreach ($fields[$g] ?? [] as $item) { + if (! ($item['disclosure'] ?? false)) { + continue; } - $public[$g][]=$visible; + $keys = ['label', 'knowledge', 'source_reference', 'source_date', 'scope']; + if ($g === 'conditions') { + $keys = [...$keys, 'value', 'currency', 'unit', 'period', 'valid_from', 'valid_until', 'modality']; + } + if ($g === 'risks') { + $keys = [...$keys, 'risk_type', 'mitigation', 'responsible_role', 'channel', 'channel_owner']; + } + $visible = array_intersect_key($item, array_flip($keys)); + if ($g === 'responsibilities') { + $org = empty($item['organization_id']) ? null : DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->where('data_classification', 'public')->first(); + abort_unless($org && $item['knowledge'] === 'documented', 422, 'Divulgação de função exige entidade pública existente e fundamento; menção preliminar continua privada.'); + $visible = [...$visible, 'role' => $item['role'], 'organization_name' => $org->name, 'public_contact' => $item['public_contact'] ?? null, 'declared_nature' => $item['declared_nature'] ?? null, 'management_model' => $item['management_model'] ?? null]; + } + $public[$g][] = $visible; } } - if(isset($fields['integral'])){$public['integral']=app(InvestmentZoneIntegralDetails::class)->publicData($fields['integral']);} - $public['review']=array_intersect_key($fields['review'] ?? [],array_flip(['source_reference','source_date','scope','confidence','last_reviewed_at','next_review_at'])); - return ['schema_version'=>1,'public'=>$public,'private_ciphertext'=>Crypt::encryptString(json_encode($fields,JSON_THROW_ON_ERROR))]; + if (isset($fields['integral'])) { + $public['integral'] = app(InvestmentZoneIntegralDetails::class)->publicData($fields['integral']); + } + $public['review'] = array_intersect_key($fields['review'] ?? [], array_flip(['source_reference', 'source_date', 'scope', 'confidence', 'last_reviewed_at', 'next_review_at'])); + + return ['schema_version' => 1, 'public' => $public, 'private_ciphertext' => Crypt::encryptString(json_encode($fields, JSON_THROW_ON_ERROR))]; } } diff --git a/app/Services/InvestmentZoneOperationalDetailsInstallation.php b/app/Services/InvestmentZoneOperationalDetailsInstallation.php index ce18a6b0..033d93ba 100644 --- a/app/Services/InvestmentZoneOperationalDetailsInstallation.php +++ b/app/Services/InvestmentZoneOperationalDetailsInstallation.php @@ -3,8 +3,10 @@ namespace App\Services; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -12,75 +14,140 @@ use Symfony\Component\HttpKernel\Exception\HttpException; /** Nominal schema installation. No imports, backfill, publication, permissions or flags. */ class InvestmentZoneOperationalDetailsInstallation { - public const SOURCE='investment-zone-details-installation'; + public const SOURCE = 'investment-zone-details-installation'; + + /** + * @return array + */ public function files(): array { return ['app/Models/InvestmentZone.php', 'app/Http/Controllers/InvestmentZoneController.php', 'app/Services/InvestmentZones.php', 'app/Services/InvestmentZoneOperationalProfile.php', 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investment/zones.tsx', 'routes/investment-zones.php', 'resources/js/components/investment-zone-installation.tsx', 'app/Services/InvestmentZoneOperationalDetails.php', 'app/Services/InvestmentZoneOperationalDetailsInstallation.php', 'resources/js/components/investment-zone-operational-details.tsx', 'database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php']; } + public function fingerprint(): string { - $files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files()); - foreach(\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $file){$files[]=['public/build/'.$file->getRelativePathname(),hash_file('sha256',$file->getPathname())];} - usort($files,fn($a,$b)=>strcmp($a[0],$b[0])); - return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR)); + $files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()); + foreach (File::allFiles(public_path('build')) as $file) { + $files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())]; + } + usort($files, fn ($a, $b) => strcmp($a[0], $b[0])); + + return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR)); } - private function authorize(User $actor,bool $write=false): void + + private function authorize(User $actor, bool $write = false): void { - abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.'); - if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} + abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.'); + if ($write) { + app(PortalProfileInstallation::class)->authorize($actor, true); + } } - private function evidence(array $target,string $code): array + + /** + * @param array $target + * @return array + */ + private function evidence(array $target, string $code): array { - $qa=app()->environment('testing') && (int)$target['port']===55441 && str_ends_with($target['database'],'_test') - && config('database.connections.pgsql.host')==='127.0.0.1' && !config('database.connections.pgsql.url'); - if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];} - if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];} - $path=storage_path('app/private/investmoz-v2/park-details/readiness.json'); - $p=is_file($path)?json_decode(file_get_contents($path),true):[];$date=strtotime($p['verified_at'] ?? '');$auth=$p['active_authorization'] ?? []; - $ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url') - && in_array(config('database.connections.pgsql.host'),['127.0.0.1','localhost'],true) - && ($p['target']['database'] ?? '')===$target['database'] && (int)($p['target']['port'] ?? 0)===(int)$target['port'] - && ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400 + $qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') + && config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url'); + if ($qa) { + return ['ready' => true, 'mode' => 'isolated_QA']; + } + if (app()->environment('testing')) { + return ['ready' => false, 'mode' => 'invalid_QA']; + } + $path = storage_path('app/private/investmoz-v2/park-details/readiness.json'); + $p = is_file($path) ? json_decode(CheckedFile::read($path), true) : []; + $date = strtotime($p['verified_at'] ?? ''); + $auth = $p['active_authorization'] ?? []; + $ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url') + && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true) + && ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port'] + && ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400 && ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false) - && ($p['restored_boot_verified'] ?? false) && (int)($p['restore_target']['port'] ?? 0)===55441 && str_ends_with($p['restore_target']['database'] ?? '','_test') - && ($auth['decision_record'] ?? '')!=='' && ($auth['code_hash'] ?? '')===$code && ($auth['migration'] ?? '')===InvestmentZoneOperationalDetails::MIGRATION; - foreach(['dump','files'] as $kind){$file=$p[$kind.'_path'] ?? ''; $ready=$ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '',hash_file('sha256',$file));} - return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null]; + && ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test') + && ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === InvestmentZoneOperationalDetails::MIGRATION; + foreach (['dump', 'files'] as $kind) { + $file = $p[$kind.'_path'] ?? ''; + $ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file)); + } + + return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null]; } + + /** + * @return array + */ public function preview(User $actor): array { - $this->authorize($actor);$target=(array)DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');$code=$this->fingerprint(); - $ledgerCount=DB::table('migrations')->where('migration',InvestmentZoneOperationalDetails::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('investment_zones','operational_details'); - $shape=DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='operational_details'"); - $correct=$shape && $shape->data_type==='jsonb' && $shape->is_nullable==='YES'; - $consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[]; - if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';} - try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();} - $evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';} - if(!app(InvestmentZoneOperationalProfile::class)->installed() || DB::table('migrations')->where('migration',InvestmentZoneOperationalProfile::MIGRATION)->count()!==1){$reasons[]='Base 130000 ausente ou divergente; instalar/verificar antes de L1.';} - $state=['target'=>$target,'migration'=>InvestmentZoneOperationalDetails::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column, - 'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence]; - return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))]; + $this->authorize($actor); + $target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); + $code = $this->fingerprint(); + $ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalDetails::MIGRATION)->count(); + $ledger = $ledgerCount === 1; + $column = Schema::hasColumn('investment_zones', 'operational_details'); + $shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='operational_details'"); + $correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES'; + $consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column); + $reasons = []; + if (! $consistent) { + $reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.'; + } + try { + $this->authorize($actor, true); + } catch (HttpException $e) { + $reasons[] = $e->getMessage(); + } + $evidence = $this->evidence($target, $code); + if (! $evidence['ready']) { + $reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.'; + } + if (! app(InvestmentZoneOperationalProfile::class)->installed() || DB::table('migrations')->where('migration', InvestmentZoneOperationalProfile::MIGRATION)->count() !== 1) { + $reasons[] = 'Base 130000 ausente ou divergente; instalar/verificar antes de L1.'; + } + $state = ['target' => $target, 'migration' => InvestmentZoneOperationalDetails::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column, + 'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence]; + + return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))]; } - public function apply(User $actor,array $input): array + + /** + * @param array $input + * @return array + */ + public function apply(User $actor, array $input): array { - $this->authorize($actor,true); - abort_if(array_diff(array_keys($input),['hash','idempotency_key','confirm','_token']),422,'Ator, migration ou caminho arbitrário não permitido.'); - $data=Validator::make($input,['hash'=>'required|string|size:64','idempotency_key'=>'required|uuid','confirm'=>'required|accepted'])->validate(); - return DB::transaction(function()use($actor,$data){ - DB::select('SELECT pg_advisory_xact_lock(161026,140000)');$requestHash=hash('sha256',json_encode($data,JSON_THROW_ON_ERROR)); - $old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first(); - if($old){$receipt=json_decode($old->after_data,true);abort_unless((int)$old->actor_user_id===$actor->id && hash_equals($receipt['request_hash'],$requestHash),409,'Chave já utilizada com outro ator ou conteúdo.');return [...$receipt['result'],'replayed'=>true];} - $plan=$this->preview($actor);abort_unless(hash_equals($plan['hash'],$data['hash']),409,'Prévia alterada; consulte novamente.');abort_unless($plan['ready'],422,implode(' ',$plan['reasons'])); - if(!$plan['installed']){ - config(['investment-zone-details.authenticated_installation'=>true]); - try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.InvestmentZoneOperationalDetails::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');} - finally{config(['investment-zone-details.authenticated_installation'=>false]);} + $this->authorize($actor, true); + abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.'); + $data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate(); + + return DB::transaction(function () use ($actor, $data) { + DB::select('SELECT pg_advisory_xact_lock(161026,140000)'); + $requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first(); + if ($old) { + $receipt = json_decode($old->after_data, true); + abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.'); + + return [...$receipt['result'], 'replayed' => true]; } - abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.'); - $result=['migration'=>$plan['migration'],'code_hash'=>$plan['code_hash'],'target'=>$plan['target'],'changed'=>!$plan['installed'],'business_records_changed'=>0,'replayed'=>false,'receipt'=>$data['idempotency_key']]; - DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'investment-zone-profile','entity_id'=>null,'actor_user_id'=>$actor->id, - 'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]); + $plan = $this->preview($actor); + abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.'); + abort_unless($plan['ready'], 422, implode(' ', $plan['reasons'])); + if (! $plan['installed']) { + config(['investment-zone-details.authenticated_installation' => true]); + try { + abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.InvestmentZoneOperationalDetails::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.'); + } finally { + config(['investment-zone-details.authenticated_installation' => false]); + } + } + abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.'); + $result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']]; + DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'investment-zone-profile', 'entity_id' => null, 'actor_user_id' => $actor->id, + 'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]); + return $result; }); } diff --git a/app/Services/InvestmentZoneOperationalProfile.php b/app/Services/InvestmentZoneOperationalProfile.php index ceccba75..05c06281 100644 --- a/app/Services/InvestmentZoneOperationalProfile.php +++ b/app/Services/InvestmentZoneOperationalProfile.php @@ -13,131 +13,186 @@ use Illuminate\Validation\ValidationException; class InvestmentZoneOperationalProfile { public const MIGRATION = '2026_10_16_130000_add_investment_zone_editorial_draft'; - public const FIELDS = ['code','category','name','name_en','description','description_en','province','district','operator_id','legal_source_id', - 'physical_category','legal_regime','operational_status','development_status','availability_status','area_components','source_evidence', - 'area_value','area_unit','aliases','activities','resources','data_as_of','sources_consulted_at','parent_zone_id','historical_territory']; - public const DETAIL_PUBLIC = ['id','name','status','source_reference','source_date','kind','scope','capacity_type','capacity_value','capacity_unit','focus','economic_activity_id']; - public function installed(): bool { return Schema::hasColumn('investment_zones', 'editorial_draft'); } + public const FIELDS = ['code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'operator_id', 'legal_source_id', + 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_components', 'source_evidence', + 'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory']; + public const DETAIL_PUBLIC = ['id', 'name', 'status', 'source_reference', 'source_date', 'kind', 'scope', 'capacity_type', 'capacity_value', 'capacity_unit', 'focus', 'economic_activity_id']; + + public function installed(): bool + { + return Schema::hasColumn('investment_zones', 'editorial_draft'); + } + + /** + * @return array|null + */ public function draft(InvestmentZone $zone): ?array { $raw = $zone->getRawOriginal('editorial_draft'); - if (! $raw) { return null; } - try { $data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR); } - catch (\Throwable $e) { throw ValidationException::withMessages(['editorial_draft'=>'Não foi possível recuperar a preparação privada. Os dados foram preservados; contacte o suporte.']); } + if (! $raw) { + return null; + } + try { + $data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $e) { + throw ValidationException::withMessages(['editorial_draft' => 'Não foi possível recuperar a preparação privada. Os dados foram preservados; contacte o suporte.']); + } abort_unless(($data['schema_version'] ?? null) === 1 && is_array($data['fields'] ?? null), 409, 'Formato de preparação incompatível; os dados foram preservados.'); + return $data; } - public function workingVersion(InvestmentZone $zone): int { return (int) ($this->draft($zone)['working_version'] ?? $zone->version_no); } + public function workingVersion(InvestmentZone $zone): int + { + return (int) ($this->draft($zone)['working_version'] ?? $zone->version_no); + } public function nextVersion(InvestmentZone $zone): int { - return max((int) $zone->version_no, (int) DB::table('investment_zone_revisions')->where('zone_id',$zone->id)->max('version_no')) + 1; + return max((int) $zone->version_no, (int) DB::table('investment_zone_revisions')->where('zone_id', $zone->id)->max('version_no')) + 1; } + /** + * @return array + */ public function management(InvestmentZone $zone): array { $draft = $this->draft($zone); - return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no'=>$draft['working_version'] ?? $zone->version_no, - 'public_version_no'=>$zone->version_no, 'base_public_version'=>$draft['base_public_version'] ?? $zone->version_no, - 'has_private_draft'=>$draft !== null, 'profile_available'=>$this->installed(), - 'operational_details'=>($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)), - 'details_available'=>app(InvestmentZoneOperationalDetails::class)->installed()]; + + return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no, + 'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no, + 'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(), + 'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)), + 'details_available' => app(InvestmentZoneOperationalDetails::class)->installed()]; } + /** + * @param array $record + * @return array + */ public function publicDetails(array $record): array { unset($record['editorial_draft'], $record['operational_details']); - foreach (['activities','resources'] as $key) { - if (isset($record[$key])) { $record[$key] = array_map(fn ($item)=>array_intersect_key($item,array_flip(self::DETAIL_PUBLIC)), $record[$key]); } + foreach (['activities', 'resources'] as $key) { + if (isset($record[$key])) { + $record[$key] = array_map(fn ($item) => array_intersect_key($item, array_flip(self::DETAIL_PUBLIC)), $record[$key]); + } } + return $record; } - private function mergeDetails(string $zoneId,array $before, array $after): array + /** + * @param array $after + * @param array $before + * @return array + */ + private function mergeDetails(string $zoneId, array $before, array $after): array { - $result=[]; + $result = []; foreach ($after as $item) { - $old = collect($before)->first(fn($b)=>isset($item['id']) ? ($b['id'] ?? null)===$item['id'] : ($b['name'] ?? null)===($item['name'] ?? null)); - $item=[...($old ?? []), ...$item]; + $old = collect($before)->first(fn ($b) => isset($item['id']) ? ($b['id'] ?? null) === $item['id'] : ($b['name'] ?? null) === ($item['name'] ?? null)); + $item = [...($old ?? []), ...$item]; $item['id'] ??= (string) Str::uuid(); - if (isset($item['capacity_value']) && empty($item['capacity_unit'])) { throw ValidationException::withMessages(['resources'=>'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']); } - if (!empty($item['economic_activity_id'])) { - abort_unless(trim((string)($item['source_reference'] ?? '')) !== '' && DB::table('economic_activities')->where('id',$item['economic_activity_id'])->where('data_classification','public')->whereNull('deleted_at')->exists(),422,'A correspondência CAE exige referência publicada e fundamento explícito.'); + if (isset($item['capacity_value']) && empty($item['capacity_unit'])) { + throw ValidationException::withMessages(['resources' => 'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']); + } + if (! empty($item['economic_activity_id'])) { + abort_unless(trim((string) ($item['source_reference'] ?? '')) !== '' && DB::table('economic_activities')->where('id', $item['economic_activity_id'])->where('data_classification', 'public')->whereNull('deleted_at')->exists(), 422, 'A correspondência CAE exige referência publicada e fundamento explícito.'); } foreach ($item['support_document_ids'] ?? [] as $document) { - abort_unless(DB::table('investment_zone_attachments')->where('zone_id',$zoneId)->where('document_id',$document)->exists(),422,'Selecione suporte privado já associado a esta ficha.'); - $d=DB::table('documents')->where('id',$document)->whereNull('deleted_at')->first(); - abort_unless($d && $d->status==='active' && $d->malware_scan_status==='clean' && trim($d->sha256)===$d->malware_scanned_sha256,422,'Documento de suporte ainda não consultável.'); + abort_unless(DB::table('investment_zone_attachments')->where('zone_id', $zoneId)->where('document_id', $document)->exists(), 422, 'Selecione suporte privado já associado a esta ficha.'); + $d = DB::table('documents')->where('id', $document)->whereNull('deleted_at')->first(); + abort_unless($d && $d->status === 'active' && $d->malware_scan_status === 'clean' && trim($d->sha256) === $d->malware_scanned_sha256, 422, 'Documento de suporte ainda não consultável.'); } - $result[]=$item; + $result[] = $item; } + return $result; } - /** Called with zone row locked by InvestmentZones. Only this encrypted column is written. */ - public function prepare(InvestmentZone $zone,array $data,int $actor): InvestmentZone + /** Called with zone row locked by InvestmentZones. Only this encrypted column is written. + * @param array $data + */ + public function prepare(InvestmentZone $zone, array $data, int $actor): InvestmentZone { - abort_unless($this->installed(),503,'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.'); - abort_unless((int)$data['version_no']===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de guardar.'); - $previous=$this->draft($zone); - $base=$previous['fields'] ?? array_intersect_key($zone->toArray(),array_flip(self::FIELDS)); - $fields=[...$base,...array_intersect_key($data,array_flip(self::FIELDS))]; - $details=app(InvestmentZoneOperationalDetails::class); - if (array_key_exists('operational_details',$data)) { - $fields['operational_details']=$details->merge($zone,$base['operational_details'] ?? $details->recover($zone),$data['operational_details'],$fields['operator_id'] ?? null); - } elseif ($details->stored($zone) && !array_key_exists('operational_details',$fields)) { - $fields['operational_details']=$details->recover($zone); + abort_unless($this->installed(), 503, 'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.'); + abort_unless((int) $data['version_no'] === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de guardar.'); + $previous = $this->draft($zone); + $base = $previous['fields'] ?? array_intersect_key($zone->toArray(), array_flip(self::FIELDS)); + $fields = [...$base, ...array_intersect_key($data, array_flip(self::FIELDS))]; + $details = app(InvestmentZoneOperationalDetails::class); + if (array_key_exists('operational_details', $data)) { + $fields['operational_details'] = $details->merge($zone, $base['operational_details'] ?? $details->recover($zone), $data['operational_details'], $fields['operator_id'] ?? null); + } elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) { + $fields['operational_details'] = $details->recover($zone); } - $details=app(InvestmentZoneOperationalDetails::class); - if (array_key_exists('operational_details',$data)) { - $fields['operational_details']=$details->merge($zone,$base['operational_details'] ?? $details->recover($zone),$data['operational_details'],$fields['operator_id'] ?? null); - } elseif ($details->stored($zone) && !array_key_exists('operational_details',$fields)) { - $fields['operational_details']=$details->recover($zone); + $details = app(InvestmentZoneOperationalDetails::class); + if (array_key_exists('operational_details', $data)) { + $fields['operational_details'] = $details->merge($zone, $base['operational_details'] ?? $details->recover($zone), $data['operational_details'], $fields['operator_id'] ?? null); + } elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) { + $fields['operational_details'] = $details->recover($zone); } - foreach (['activities','resources'] as $key) { if (array_key_exists($key,$data)) { $fields[$key]=$this->mergeDetails($zone->id,$base[$key] ?? [],$data[$key]); } } - $draft=['schema_version'=>1,'base_public_version'=>$previous['base_public_version'] ?? (int)$zone->version_no, - 'working_version'=>$this->nextVersion($zone),'fields'=>$fields,'actor_id'=>$actor,'prepared_at'=>now()->toIso8601String()]; - DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]); - $this->snapshot($zone,$draft['working_version'],$actor,'profile_prepared',['public'=>[...$zone->toArray(),'operational_details'=>$details->stored($zone)],'draft'=>$draft]); + foreach (['activities', 'resources'] as $key) { + if (array_key_exists($key, $data)) { + $fields[$key] = $this->mergeDetails($zone->id, $base[$key] ?? [], $data[$key]); + } + } + $draft = ['schema_version' => 1, 'base_public_version' => $previous['base_public_version'] ?? (int) $zone->version_no, + 'working_version' => $this->nextVersion($zone), 'fields' => $fields, 'actor_id' => $actor, 'prepared_at' => now()->toIso8601String()]; + DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]); + $this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]); + return $zone->fresh(); } /** Apply only inside the publication transaction; a failed validation rolls everything back. */ - public function promote(InvestmentZone $zone,int $version): void + public function promote(InvestmentZone $zone, int $version): void { - $draft=$this->draft($zone); - abort_unless($version===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de publicar.'); - if (!$draft) { return; } - abort_unless((int)$draft['base_public_version']===(int)$zone->version_no,409,'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.'); - $zone->fill(array_intersect_key($draft['fields'],array_flip(self::FIELDS))); - if (array_key_exists('operational_details',$draft['fields'])) { - $zone->setAttribute('operational_details',app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details'])); + $draft = $this->draft($zone); + abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.'); + if (! $draft) { + return; + } + abort_unless((int) $draft['base_public_version'] === (int) $zone->version_no, 409, 'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.'); + $zone->fill(array_intersect_key($draft['fields'], array_flip(self::FIELDS))); + if (array_key_exists('operational_details', $draft['fields'])) { + $zone->setAttribute('operational_details', app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details'])); } // Existing resource support remains in the encrypted snapshot; L1 private fields are durable. - foreach(['activities','resources'] as $key) { $zone->setAttribute($key,$this->publicDetails([$key=>$zone->$key ?? []])[$key]); } - $zone->setAttribute('editorial_draft',null); + foreach (['activities', 'resources'] as $key) { + $zone->setAttribute($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]); + } + $zone->setAttribute('editorial_draft', null); } - public function reconcile(InvestmentZone $zone,int $working,int $public,int $actor): InvestmentZone + public function reconcile(InvestmentZone $zone, int $working, int $public, int $actor): InvestmentZone { - $draft=$this->draft($zone); - abort_unless($draft && $working===(int)$draft['working_version'] && $public===(int)$zone->version_no,409,'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.'); - abort_if((int)$draft['base_public_version']===$public,422,'A preparação já corresponde à versão pública atual.'); - $draft['base_public_version']=$public;$draft['working_version']=$this->nextVersion($zone);$draft['actor_id']=$actor;$draft['prepared_at']=now()->toIso8601String(); - DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]); - $this->snapshot($zone,$draft['working_version'],$actor,'profile_reconciled',['public'=>[...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],'draft'=>$draft]); + $draft = $this->draft($zone); + abort_unless($draft && $working === (int) $draft['working_version'] && $public === (int) $zone->version_no, 409, 'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.'); + abort_if((int) $draft['base_public_version'] === $public, 422, 'A preparação já corresponde à versão pública atual.'); + $draft['base_public_version'] = $public; + $draft['working_version'] = $this->nextVersion($zone); + $draft['actor_id'] = $actor; + $draft['prepared_at'] = now()->toIso8601String(); + DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]); + $this->snapshot($zone, $draft['working_version'], $actor, 'profile_reconciled', ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'draft' => $draft]); + return $zone->fresh(); } - public function snapshot(InvestmentZone $zone,int $version,int $actor,string $action,array $data): void + /** + * @param array $data + */ + public function snapshot(InvestmentZone $zone, int $version, int $actor, string $action, array $data): void { - $json=json_encode($data,JSON_THROW_ON_ERROR);$hash=hash('sha256',$json); - DB::table('investment_zone_revisions')->insert(['id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'version_no'=>$version, - 'actor_id'=>$actor,'action'=>$action,'snapshot'=>Crypt::encryptString($json),'sha256'=>$hash]); - DB::table('audit_events')->insert(['source_system'=>'investment-zones','entity_type'=>'investment_zones','entity_id'=>$zone->id, - 'actor_user_id'=>$actor,'action'=>$action,'after_data'=>json_encode(['version_no'=>$version,'sha256'=>$hash],JSON_THROW_ON_ERROR)]); + $json = json_encode($data, JSON_THROW_ON_ERROR); + $hash = hash('sha256', $json); + DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version, + 'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]); + DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id, + 'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version, 'sha256' => $hash], JSON_THROW_ON_ERROR)]); } } diff --git a/app/Services/InvestmentZonePathContext.php b/app/Services/InvestmentZonePathContext.php index 8d61c9f8..b0d75eb4 100644 --- a/app/Services/InvestmentZonePathContext.php +++ b/app/Services/InvestmentZonePathContext.php @@ -8,41 +8,55 @@ use Illuminate\Support\Facades\Validator; class InvestmentZonePathContext { - public const NOTICE='Referência para preparação; não constitui reserva de lote, confirmação de disponibilidade ou autorização de instalação.'; + public const NOTICE = 'Referência para preparação; não constitui reserva de lote, confirmação de disponibilidade ou autorização de instalação.'; - public function context(string $id,int $version): array + /** + * @return array + */ + public function context(string $id, int $version): array { - $zone=app(InvestmentZones::class)->visible()->where('category','industrial_park')->findOrFail($id); - abort_unless((int)$zone->version_no===$version,409,'A ficha pública mudou. Consulte a versão atual e confirme novamente; a seleção anterior não foi substituída.'); - return ['zone_id'=>$zone->id,'consulted_public_version'=>(int)$zone->version_no,'title'=>$zone->name,'href'=>'/zonas/'.$zone->id, - 'province'=>$zone->province,'district'=>$zone->district,'notice'=>self::NOTICE]; + $zone = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->findOrFail($id); + abort_unless((int) $zone->version_no === $version, 409, 'A ficha pública mudou. Consulte a versão atual e confirme novamente; a seleção anterior não foi substituída.'); + + return ['zone_id' => $zone->id, 'consulted_public_version' => (int) $zone->version_no, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id, + 'province' => $zone->province, 'district' => $zone->district, 'notice' => self::NOTICE]; } + /** + * @param array $input + * @return array + */ public function input(array $input): array { - return Validator::make($input,['zone_id'=>'required|uuid','consulted_public_version'=>'required|integer|min:1'])->validate(); + return Validator::make($input, ['zone_id' => 'required|uuid', 'consulted_public_version' => 'required|integer|min:1'])->validate(); } - public function attach(User $actor,string $id,array $input): void + /** + * @param array $input + */ + public function attach(User $actor, string $id, array $input): void { - abort_unless(app(InvestorPath::class)->available(),404); - $data=Validator::make($input,['zone_id'=>'required|uuid','consulted_public_version'=>'required|integer|min:1','version'=>'required|integer|min:1','confirm'=>'required|accepted'])->validate(); - DB::transaction(function()use($actor,$id,$data){ - $desk=app(VirtualDesk::class);$case=$desk->case($actor,$id,true,true); - abort_if($case->closed_at,409,'O dossier está encerrado.'); - abort_unless(DB::table('investor_journeys')->where('id',$case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(),409,'Este dossier conserva outro roteiro.'); - $payload=$desk->decode($case->payload); - foreach($payload['investor_path']['zone_contexts'] ?? [] as $existing){ - if($existing['zone_id']===$data['zone_id'] && (int)$existing['consulted_public_version']===(int)$data['consulted_public_version']){return;} + abort_unless(app(InvestorPath::class)->available(), 404); + $data = Validator::make($input, ['zone_id' => 'required|uuid', 'consulted_public_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1', 'confirm' => 'required|accepted'])->validate(); + DB::transaction(function () use ($actor, $id, $data) { + $desk = app(VirtualDesk::class); + $case = $desk->case($actor, $id, true, true); + abort_if($case->closed_at, 409, 'O dossier está encerrado.'); + abort_unless(DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 409, 'Este dossier conserva outro roteiro.'); + $payload = $desk->decode($case->payload); + foreach ($payload['investor_path']['zone_contexts'] ?? [] as $existing) { + if ($existing['zone_id'] === $data['zone_id'] && (int) $existing['consulted_public_version'] === (int) $data['consulted_public_version']) { + return; + } } - abort_unless((int)$case->version===(int)$data['version'],409,'O dossier mudou; recupere a versão atual.'); + abort_unless((int) $case->version === (int) $data['version'], 409, 'O dossier mudou; recupere a versão atual.'); // Lock the public record while checking the consulted version and recording its reference. - DB::table('investment_zones')->where('id',$data['zone_id'])->lockForUpdate()->firstOrFail(); - $context=$this->context($data['zone_id'],(int)$data['consulted_public_version']); - abort_unless(count($payload['investor_path']['zone_contexts'] ?? [])<100,422,'Limite de referências deste dossier atingido.'); - $payload['investor_path']['zone_contexts'][]=[...$context,'selected_at'=>now()->toIso8601String()]; - DB::table('desk_cases')->where('id',$id)->update(['payload'=>$desk->encode($payload),'version'=>$case->version+1,'updated_at'=>now()]); - $desk->event($desk->case($actor,$id),$actor,'park_reference_selected',['zone_id'=>$context['zone_id'],'consulted_public_version'=>$context['consulted_public_version'],'scope'=>'preparation_reference_only']); + DB::table('investment_zones')->where('id', $data['zone_id'])->lockForUpdate()->firstOrFail(); + $context = $this->context($data['zone_id'], (int) $data['consulted_public_version']); + abort_unless(count($payload['investor_path']['zone_contexts'] ?? []) < 100, 422, 'Limite de referências deste dossier atingido.'); + $payload['investor_path']['zone_contexts'][] = [...$context, 'selected_at' => now()->toIso8601String()]; + DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]); + $desk->event($desk->case($actor, $id), $actor, 'park_reference_selected', ['zone_id' => $context['zone_id'], 'consulted_public_version' => $context['consulted_public_version'], 'scope' => 'preparation_reference_only']); }); } } diff --git a/app/Services/InvestmentZoneProfileInstallation.php b/app/Services/InvestmentZoneProfileInstallation.php index 26d6b920..9169f9b4 100644 --- a/app/Services/InvestmentZoneProfileInstallation.php +++ b/app/Services/InvestmentZoneProfileInstallation.php @@ -3,8 +3,10 @@ namespace App\Services; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -12,79 +14,142 @@ use Symfony\Component\HttpKernel\Exception\HttpException; /** Nominal schema installation. No imports, backfill, publication, permissions or flags. */ class InvestmentZoneProfileInstallation { - public const SOURCE='investment-zone-profile-installation'; + public const SOURCE = 'investment-zone-profile-installation'; + + /** + * @return array + */ public function files(): array { - return ['app/Models/InvestmentZone.php','app/Services/InvestmentZones.php','app/Services/InvestmentZoneOperationalProfile.php', - 'app/Services/InvestmentZonePathContext.php','app/Services/InvestmentZoneProfileInstallation.php','app/Http/Controllers/InvestmentZoneController.php', - 'app/Http/Controllers/InvestorPathController.php','app/Services/InvestorPath.php','routes/investment-zones.php','routes/investor-path.php', - 'resources/js/pages/investment/zone.tsx','resources/js/pages/investment/zones.tsx','resources/js/pages/investor-path/index.tsx', - 'resources/js/pages/investor-path/show.tsx','resources/js/components/investment-zone-installation.tsx', + return ['app/Models/InvestmentZone.php', 'app/Services/InvestmentZones.php', 'app/Services/InvestmentZoneOperationalProfile.php', + 'app/Services/InvestmentZonePathContext.php', 'app/Services/InvestmentZoneProfileInstallation.php', 'app/Http/Controllers/InvestmentZoneController.php', + 'app/Http/Controllers/InvestorPathController.php', 'app/Services/InvestorPath.php', 'routes/investment-zones.php', 'routes/investor-path.php', + 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investment/zones.tsx', 'resources/js/pages/investor-path/index.tsx', + 'resources/js/pages/investor-path/show.tsx', 'resources/js/components/investment-zone-installation.tsx', 'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php']; } + public function fingerprint(): string { - $files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files()); - foreach(\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $file){$files[]=['public/build/'.$file->getRelativePathname(),hash_file('sha256',$file->getPathname())];} - usort($files,fn($a,$b)=>strcmp($a[0],$b[0])); - return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR)); + $files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()); + foreach (File::allFiles(public_path('build')) as $file) { + $files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())]; + } + usort($files, fn ($a, $b) => strcmp($a[0], $b[0])); + + return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR)); } - private function authorize(User $actor,bool $write=false): void + + private function authorize(User $actor, bool $write = false): void { - abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.'); - if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} + abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.'); + if ($write) { + app(PortalProfileInstallation::class)->authorize($actor, true); + } } - private function evidence(array $target,string $code): array + + /** + * @param array $target + * @return array + */ + private function evidence(array $target, string $code): array { - $qa=app()->environment('testing') && (int)$target['port']===55441 && str_ends_with($target['database'],'_test') - && config('database.connections.pgsql.host')==='127.0.0.1' && !config('database.connections.pgsql.url'); - if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];} - if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];} - $path=storage_path('app/private/investmoz-v2/park-profile/readiness.json'); - $p=is_file($path)?json_decode(file_get_contents($path),true):[];$date=strtotime($p['verified_at'] ?? '');$auth=$p['active_authorization'] ?? []; - $ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url') - && in_array(config('database.connections.pgsql.host'),['127.0.0.1','localhost'],true) - && ($p['target']['database'] ?? '')===$target['database'] && (int)($p['target']['port'] ?? 0)===(int)$target['port'] - && ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400 + $qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') + && config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url'); + if ($qa) { + return ['ready' => true, 'mode' => 'isolated_QA']; + } + if (app()->environment('testing')) { + return ['ready' => false, 'mode' => 'invalid_QA']; + } + $path = storage_path('app/private/investmoz-v2/park-profile/readiness.json'); + $p = is_file($path) ? json_decode(CheckedFile::read($path), true) : []; + $date = strtotime($p['verified_at'] ?? ''); + $auth = $p['active_authorization'] ?? []; + $ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url') + && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true) + && ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port'] + && ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400 && ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false) - && ($p['restored_boot_verified'] ?? false) && (int)($p['restore_target']['port'] ?? 0)===55441 && str_ends_with($p['restore_target']['database'] ?? '','_test') - && ($auth['decision_record'] ?? '')!=='' && ($auth['code_hash'] ?? '')===$code && ($auth['migration'] ?? '')===InvestmentZoneOperationalProfile::MIGRATION; - foreach(['dump','files'] as $kind){$file=$p[$kind.'_path'] ?? ''; $ready=$ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '',hash_file('sha256',$file));} - return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null]; + && ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test') + && ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === InvestmentZoneOperationalProfile::MIGRATION; + foreach (['dump', 'files'] as $kind) { + $file = $p[$kind.'_path'] ?? ''; + $ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file)); + } + + return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null]; } + + /** + * @return array + */ public function preview(User $actor): array { - $this->authorize($actor);$target=(array)DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');$code=$this->fingerprint(); - $ledgerCount=DB::table('migrations')->where('migration',InvestmentZoneOperationalProfile::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('investment_zones','editorial_draft'); - $shape=DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='editorial_draft'"); - $correct=$shape && $shape->data_type==='text' && $shape->is_nullable==='YES'; - $consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[]; - if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';} - try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();} - $evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';} - $state=['target'=>$target,'migration'=>InvestmentZoneOperationalProfile::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column, - 'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence]; - return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))]; + $this->authorize($actor); + $target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); + $code = $this->fingerprint(); + $ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalProfile::MIGRATION)->count(); + $ledger = $ledgerCount === 1; + $column = Schema::hasColumn('investment_zones', 'editorial_draft'); + $shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='editorial_draft'"); + $correct = $shape && $shape->data_type === 'text' && $shape->is_nullable === 'YES'; + $consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column); + $reasons = []; + if (! $consistent) { + $reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.'; + } + try { + $this->authorize($actor, true); + } catch (HttpException $e) { + $reasons[] = $e->getMessage(); + } + $evidence = $this->evidence($target, $code); + if (! $evidence['ready']) { + $reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.'; + } + $state = ['target' => $target, 'migration' => InvestmentZoneOperationalProfile::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column, + 'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence]; + + return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))]; } - public function apply(User $actor,array $input): array + + /** + * @param array $input + * @return array + */ + public function apply(User $actor, array $input): array { - $this->authorize($actor,true); - abort_if(array_diff(array_keys($input),['hash','idempotency_key','confirm','_token']),422,'Ator, migration ou caminho arbitrário não permitido.'); - $data=Validator::make($input,['hash'=>'required|string|size:64','idempotency_key'=>'required|uuid','confirm'=>'required|accepted'])->validate(); - return DB::transaction(function()use($actor,$data){ - DB::select('SELECT pg_advisory_xact_lock(161026,130000)');$requestHash=hash('sha256',json_encode($data,JSON_THROW_ON_ERROR)); - $old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first(); - if($old){$receipt=json_decode($old->after_data,true);abort_unless((int)$old->actor_user_id===$actor->id && hash_equals($receipt['request_hash'],$requestHash),409,'Chave já utilizada com outro ator ou conteúdo.');return [...$receipt['result'],'replayed'=>true];} - $plan=$this->preview($actor);abort_unless(hash_equals($plan['hash'],$data['hash']),409,'Prévia alterada; consulte novamente.');abort_unless($plan['ready'],422,implode(' ',$plan['reasons'])); - if(!$plan['installed']){ - config(['investment-zone-profile.authenticated_installation'=>true]); - try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');} - finally{config(['investment-zone-profile.authenticated_installation'=>false]);} + $this->authorize($actor, true); + abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.'); + $data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate(); + + return DB::transaction(function () use ($actor, $data) { + DB::select('SELECT pg_advisory_xact_lock(161026,130000)'); + $requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); + $old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first(); + if ($old) { + $receipt = json_decode($old->after_data, true); + abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.'); + + return [...$receipt['result'], 'replayed' => true]; } - abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.'); - $result=['migration'=>$plan['migration'],'code_hash'=>$plan['code_hash'],'target'=>$plan['target'],'changed'=>!$plan['installed'],'business_records_changed'=>0,'replayed'=>false,'receipt'=>$data['idempotency_key']]; - DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'investment-zone-profile','entity_id'=>null,'actor_user_id'=>$actor->id, - 'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]); + $plan = $this->preview($actor); + abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.'); + abort_unless($plan['ready'], 422, implode(' ', $plan['reasons'])); + if (! $plan['installed']) { + config(['investment-zone-profile.authenticated_installation' => true]); + try { + abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.'); + } finally { + config(['investment-zone-profile.authenticated_installation' => false]); + } + } + abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.'); + $result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']]; + DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'investment-zone-profile', 'entity_id' => null, 'actor_user_id' => $actor->id, + 'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]); + return $result; }); } diff --git a/app/Services/InvestmentZones.php b/app/Services/InvestmentZones.php index 12749dff..22625b18 100644 --- a/app/Services/InvestmentZones.php +++ b/app/Services/InvestmentZones.php @@ -97,15 +97,16 @@ class InvestmentZones return DB::transaction(function () use ($id, $data, $actor) { $zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone; if ($id && $zone->category === 'industrial_park') { - $prepared=app(InvestmentZoneOperationalProfile::class)->prepare($zone,$data,$actor); - $this->storePendingLocations($zone,$data['locations'] ?? [],$actor); + $prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor); + $this->storePendingLocations($zone, $data['locations'] ?? [], $actor); + return $prepared; } 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; $locations = $data['locations'] ?? []; unset($data['locations'], $data['version_no']); - abort_if(array_key_exists('operational_details',$data),422,'Prepare L1 numa ficha de parque existente; não cria outro cadastro.'); + abort_if(array_key_exists('operational_details', $data), 422, 'Prepare L1 numa ficha de parque existente; não cria outro cadastro.'); $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) { @@ -127,8 +128,11 @@ class InvestmentZones return DB::transaction(function () use ($id, $version, $actor) { $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $profile = app(InvestmentZoneOperationalProfile::class); - if ($zone->category === 'industrial_park') { $profile->promote($zone,$version); } - else { abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); } + if ($zone->category === 'industrial_park') { + $profile->promote($zone, $version); + } else { + abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); + } $source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->lockForUpdate()->first(); $evidence = $zone->source_evidence ?? []; $officialEvidence = collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional'], true) @@ -166,8 +170,8 @@ class InvestmentZones { return DB::transaction(function () use ($id, $version, $actor) { $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); - $profile=app(InvestmentZoneOperationalProfile::class); - abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int)$zone->version_no) !== $version, 409, 'Versão alterada.'); + $profile = app(InvestmentZoneOperationalProfile::class); + abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.'); $zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save(); $this->snapshot($zone, $actor, 'suspended'); @@ -201,7 +205,7 @@ class InvestmentZones private function snapshot(InvestmentZone $zone, int $actor, string $action): void { - $json = json_encode([...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],JSON_THROW_ON_ERROR); + $json = json_encode([...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], JSON_THROW_ON_ERROR); DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => hash('sha256', $json)]); @@ -210,14 +214,16 @@ class InvestmentZones 'after_data' => json_encode(['version_no' => $zone->version_no, 'sha256' => hash('sha256', $json)], JSON_THROW_ON_ERROR)]); } - /** Existing explicit territorial control: new associations remain pending, never promoted with the profile. */ - private function storePendingLocations(InvestmentZone $zone,array $locations,int $actor): void + /** Existing explicit territorial control: new associations remain pending, never promoted with the profile. + * @param array $locations + */ + private function storePendingLocations(InvestmentZone $zone, array $locations, int $actor): void { - foreach($locations as $location){ + 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(), + '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(), ]); } } diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index d92eb748..918847a8 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Http\Controllers\InvestmentPortfolioController; use App\Models\ExistingBusiness; use App\Models\Opportunity; use App\Models\Sector; @@ -27,63 +28,83 @@ final class InvestorContext public function __construct(private readonly VirtualDesk $desk) {} + /** + * @return array|null + */ public function draft(User $actor, string $id): ?array { $case = $this->desk->case($actor, $id, true); - $draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string)$actor->id] ?? null; - return $draft ? [...$draft, 'needs_review' => (int)$draft['version'] !== (int)$case->version] : null; + $draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string) $actor->id] ?? null; + + return $draft ? [...$draft, 'needs_review' => (int) $draft['version'] !== (int) $case->version] : null; } + /** + * @param array $input + * @return array + */ public function saveDraft(User $actor, string $id, array $input): array { - $case=$this->desk->case($actor,$id,true); - abort_if($case->closed_at,409); - $sequence = (int)($input['draft_sequence'] ?? 0); - $entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:200','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[]; + $case = $this->desk->case($actor, $id, true); + abort_if($case->closed_at, 409); + $sequence = (int) ($input['draft_sequence'] ?? 0); + $entrySources = Validator::make($input, ['entry_sources' => 'sometimes|array|max:200', 'entry_sources.*' => 'string|max:100'])->validate()['entry_sources'] ?? []; unset($input['draft_sequence'],$input['entry_sources']); abort_unless($sequence > 0, 422); $data = $this->input($input); - if (isset($data['complements'])) app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []); - foreach ($data['selection'] as $slot=>$key) { - if (!$key) continue; - $ref=$this->reference($key); - abort_unless(in_array($ref['kind'],$slot==='opportunity'?['opportunity','business']:[$slot],true),422,'Referência incompatível com a escolha.'); + if (isset($data['complements'])) { + app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []); + } + foreach ($data['selection'] as $slot => $key) { + if (! $key) { + continue; + } + $ref = $this->reference($key); + if (! (in_array($ref['kind'], $slot === 'opportunity' ? ['opportunity', 'business'] : [$slot], true))) { + abort(422, 'Referência incompatível com a escolha.'); + } } foreach ($data['opportunity_choices'] ?? [] as $choice) { $ref = $this->reference($choice['source']); abort_unless($ref['kind'] === 'opportunity', 422); - $record = Opportunity::findOrFail($ref['id']); + $record = Opportunity::whereKey($ref['id'])->firstOrFail(); app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id); $key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record); - if (!empty($choice['territory'])) { + if (! empty($choice['territory'])) { $location = $this->reference($choice['territory']); abort_unless($location['kind'] === 'territory', 422); - if ($key) abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.'); - } - } - if (isset($data['opportunity_choices'])) app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $this->state($case)['opportunity_choices'] ?? [], true); - if (!empty($data['selection']['park'])) { - $first=$data['opportunity_choices'][0] ?? null; - $source=$first['source'] ?? $data['selection']['opportunity'] ?? null; - if ($source && str_starts_with($source,'opportunity:')) { - $key=$first['corridor_key'] ?? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail(substr($source,12))); if ($key) { - $territory=$first['territory'] ?? $data['selection']['territory'] ?? null; - $ids=app(InvestmentCorridorContext::class)->provinces($key); - $territory=$territory ? substr($territory,10) : (count($ids)===1?$ids[0]->id:null); - $park=$this->reference($data['selection']['park']); - abort_unless($park['kind']==='park' && in_array($park['id'],array_column(app(InvestmentCorridorContext::class)->parks($key,$territory),'id'),true),422,'A zona ou parque não tem enquadramento compatível documentado nesta localização.'); + abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.'); } } } + if (isset($data['opportunity_choices'])) { + app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $this->state($case)['opportunity_choices'] ?? [], true); + } + if (! empty($data['selection']['park'])) { + $first = $data['opportunity_choices'][0] ?? null; + $source = $first['source'] ?? $data['selection']['opportunity'] ?? null; + if ($source && str_starts_with($source, 'opportunity:')) { + $key = $first['corridor_key'] ?? app(InvestmentCorridorContext::class)->key(Opportunity::whereKey(substr($source, 12))->firstOrFail()); + if ($key) { + $territory = $first['territory'] ?? $data['selection']['territory'] ?? null; + $ids = app(InvestmentCorridorContext::class)->provinces($key); + $territory = $territory ? substr($territory, 10) : (count($ids) === 1 ? $ids[0]->id : null); + $park = $this->reference($data['selection']['park']); + abort_unless($park['kind'] === 'park' && in_array($park['id'], array_column(app(InvestmentCorridorContext::class)->parks($key, $territory), 'id'), true), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.'); + } + } + } + return DB::transaction(function () use ($actor, $id, $data, $sequence, $entrySources) { $case = $this->desk->case($actor, $id, true, true); - abort_if($case->closed_at || (int)$case->version !== (int)$data['version'], 409, 'O dossier mudou. Recupere os dados antes de continuar.'); + abort_if($case->closed_at || (int) $case->version !== (int) $data['version'], 409, 'O dossier mudou. Recupere os dados antes de continuar.'); $payload = $this->desk->decode($case->payload); - $previous = $payload['investor_path']['context_drafts'][(string)$actor->id] ?? []; + $previous = $payload['investor_path']['context_drafts'][(string) $actor->id] ?? []; abort_if(($previous['draft_sequence'] ?? 0) > $sequence, 409, 'Existe um rascunho mais recente. Recupere o dossier antes de continuar.'); - $payload['investor_path']['context_drafts'][(string)$actor->id] = [...$data, 'draft_sequence' => $sequence, 'entry_sources'=>array_values(array_unique([...($previous['entry_sources']??[]),...$entrySources]))]; + $payload['investor_path']['context_drafts'][(string) $actor->id] = [...$data, 'draft_sequence' => $sequence, 'entry_sources' => array_values(array_unique([...($previous['entry_sources'] ?? []), ...$entrySources]))]; DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload)]); + return ['saved' => true]; }); } @@ -93,6 +114,9 @@ final class InvestorContext return Schema::hasTable('investor_navigation_preferences'); } + /** + * @return array + */ public function state(\stdClass $case): array { $payload = $this->desk->decode($case->payload); @@ -112,7 +136,9 @@ final class InvestorContext } } } - if (!empty($context['opportunity_choices']) && !array_key_exists('park', $context['opportunity_choices'][0])) $context['opportunity_choices'][0]['park'] = $context['selection']['park'] ?? null; + if (! empty($context['opportunity_choices']) && ! array_key_exists('park', $context['opportunity_choices'][0])) { + $context['opportunity_choices'][0]['park'] = $context['selection']['park'] ?? null; + } $zone = $context['selection']['park'] ?? null; $context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general'; $context['fields']['special_zone_id'] = $zone['id'] ?? ''; @@ -120,13 +146,22 @@ final class InvestorContext return $context; } + /** + * @param array $selection + */ public function dependencyHash(array $selection): string { unset($selection['portfolio'], $selection['selection']['portfolio']); - if (isset($selection['opportunity_choices'])) $selection['opportunity_choices'] = array_map(fn($choice)=>Arr::except($choice,['portfolio_origins']),$selection['opportunity_choices']); + if (isset($selection['opportunity_choices'])) { + $selection['opportunity_choices'] = array_map(fn ($choice) => Arr::except($choice, ['portfolio_origins']), $selection['opportunity_choices']); + } + return hash('sha256', json_encode(Arr::sortRecursive($selection), JSON_THROW_ON_ERROR)); } + /** + * @return array + */ public function reference(string $key): array { [$kind,$id] = array_pad(explode(':', $key, 2), 2, ''); @@ -140,7 +175,7 @@ final class InvestorContext $zoneAnalysis = null; if ($kind === 'opportunity') { abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.'); - $o = Opportunity::with('sector')->findOrFail((int) $id); + $o = Opportunity::with('sector')->whereKey((int) $id)->firstOrFail(); $published = Opportunity::published()->whereKey($o->id)->exists(); if (! $published) { abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.'); @@ -160,7 +195,9 @@ final class InvestorContext $fields['sector'] = $o->sector->name; } $proactive = $published ? app(ProactiveInvestment::class)->publicData($o) : ($draft['proactive'] ?? null); - if ($proactive) $zoneAnalysis = ['proactive' => $proactive, 'source_key' => $key, 'source_version' => $version, 'private_source' => !$published]; + if ($proactive) { + $zoneAnalysis = ['proactive' => $proactive, 'source_key' => $key, 'source_version' => $version, 'private_source' => ! $published]; + } // Text labels do not establish a canonical territorial correspondence. if ($o->province) { $fields['province'] = $o->province; @@ -173,7 +210,7 @@ final class InvestorContext } } elseif ($kind === 'portfolio') { abort_unless(Str::isUuid($id), 422); - $dossier = \App\Http\Controllers\InvestmentPortfolioController::visible()->findOrFail($id); + $dossier = InvestmentPortfolioController::visible()->whereKey($id)->firstOrFail(); $title = $dossier->title; $href = '/carteira/'.$id; // Documentary coverage is not the investor's chosen implantation. @@ -185,7 +222,7 @@ final class InvestorContext 'geography' => app(InvestmentRelations::class)->dossier($dossier), 'available_opportunity' => false]; } elseif ($kind === 'business') { abort_unless(config('existing-business.enabled') && Str::isUuid($id), 404); - $o = ExistingBusiness::published()->with('sector')->findOrFail($id); + $o = ExistingBusiness::published()->with('sector')->whereKey($id)->firstOrFail(); $title = $o->public_title; $href = '/negocios/'.$o->public_slug; $version = $o->version_no; @@ -198,7 +235,9 @@ final class InvestorContext if ($location) { $leaf = $location->province_id; foreach (['district', 'admin_post', 'locality'] as $level) { - if (in_array($location->precision_level, array_slice(['province', 'district', 'admin_post', 'locality', 'exact'], array_search($level, ['province', 'district', 'admin_post', 'locality', 'exact'])), true) && $location->{$level.'_id'}) { + if (in_array($location->precision_level, match ($level) { + 'district' => ['district', 'admin_post', 'locality', 'exact'], 'admin_post' => ['admin_post', 'locality', 'exact'], 'locality' => ['locality', 'exact'] + }, true) && $location->{$level.'_id'}) { $leaf = $location->{$level.'_id'}; } } @@ -208,7 +247,7 @@ final class InvestorContext } } elseif ($kind === 'sector') { abort_unless(ctype_digit($id), 422); - $s = Sector::where('is_active', true)->findOrFail((int) $id); + $s = Sector::where('is_active', true)->whereKey((int) $id)->firstOrFail(); $title = $s->name; $href = SectorOrganization::href($s->slug); $version = $s->updated_at?->toIso8601String(); @@ -216,14 +255,14 @@ final class InvestorContext } elseif ($kind === 'territory') { abort_unless(Str::isUuid($id), 422); $t = app(InvestmentGeography::class)->publicReferences()->whereIn('location_type', array_values(InvestmentGeography::FIELDS))->where('id', $id)->first(); - abort_unless($t, 404, 'Território público indisponível.'); + abort_unless($t !== null, 404, 'Território público indisponível.'); $title = $t->name; $version = $t->version_no; $href = '/oportunidades?'.$t->location_type.'_id='.$id; $fields = $this->territoryFields($id); } elseif ($kind === 'park') { abort_unless(Str::isUuid($id), 422); - $p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id); + $p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->whereKey($id)->firstOrFail(); $title = $p->name; $fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category]; $version = $p->version_no; @@ -249,6 +288,9 @@ final class InvestorContext return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))]; } + /** + * @return array + */ private function territoryFields(string $id): array { $path = app(InvestmentGeography::class)->path($id); @@ -271,6 +313,9 @@ final class InvestorContext return $fields; } + /** + * @return array + */ public function options(string $query = ''): array { $query = mb_substr(trim($query), 0, 120); @@ -296,6 +341,9 @@ final class InvestorContext 'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])]; } + /** + * @return array + */ public function current(User $actor): array { if (! $this->installed()) { @@ -322,6 +370,10 @@ final class InvestorContext return ['installed' => true, 'focus' => ['case_id' => $case->id, 'step_code' => $p->step_code, 'version' => (int) $p->version]]; } + /** + * @param array $input + * @return array + */ public function focus(User $actor, array $input): array { abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.'); @@ -344,9 +396,13 @@ final class InvestorContext }); } + /** + * @param array $input + * @return array + */ private function input(array $input): array { - abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.'); + abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']) !== [], 422, 'Campo de controlo desconhecido.'); abort_if(! app(PrivateInvestmentPreparation::class)->available() && (! empty($input['document_links']) || array_intersect(array_keys(is_array($input['declarations'] ?? null) ? $input['declarations'] : []), PrivateInvestmentPreparation::FIELDS)), 409, 'Esta preparação ainda não está disponível neste ambiente. As respostas foram preservadas.'); @@ -375,6 +431,10 @@ final class InvestorContext 'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate(); } + /** + * @param array $input + * @return array + */ public function preview(User $actor, string $id, array $input): array { $data = $this->input($input); @@ -397,7 +457,7 @@ final class InvestorContext if (! array_key_exists('opportunity_choices', $data) && array_key_exists('territory', $data['selection'])) { $source = $data['selection']['opportunity'] ?? ($old['selection']['opportunity']['key'] ?? null); if ($source && str_starts_with($source, 'opportunity:')) { - $documented = Opportunity::findOrFail(substr($source, 12))->primary_territory_id; + $documented = Opportunity::whereKey(substr($source, 12))->firstOrFail()->primary_territory_id; abort_if($documented && $data['selection']['territory'] !== 'territory:'.$documented, 422, 'Confirme a alternativa territorial por ficha e indique o motivo; a localização documentada foi preservada.'); } @@ -407,15 +467,21 @@ final class InvestorContext $data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null; // Shared fields concern the first identified opportunity; other sites remain separate. $data['selection']['territory'] = $choices[0]['territory']['key'] ?? null; - if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) $data['selection']['park'] = $choices[0]['park']['key'] ?? null; + if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) { + $data['selection']['park'] = $choices[0]['park']['key'] ?? null; + } } elseif (array_key_exists('opportunity', $data['selection']) && $choices !== []) { abort(422, 'Actualize a selecção identificada de oportunidades; não substitua silenciosamente o conjunto por uma ficha.'); } $selection = $old['selection']; foreach ($complements as $complement) { - if (($complement['availability'] ?? '') === 'review_required') continue; + if (($complement['availability'] ?? '') === 'review_required') { + continue; + } foreach ([$complement['origin']['key'], $complement['target']['key'] ?? ''] as $key) { - if (str_starts_with($key, 'opportunity:')) abort_unless(in_array($key, array_column(array_column($choices, 'opportunity'), 'key'), true), 422, 'Conserve a oportunidade escolhida ou retire explicitamente a sua relação complementar.'); + if (str_starts_with($key, 'opportunity:')) { + abort_unless(in_array($key, array_column(array_column($choices, 'opportunity'), 'key'), true), 422, 'Conserve a oportunidade escolhida ou retire explicitamente a sua relação complementar.'); + } } } foreach ($data['selection'] as $slot => $key) { @@ -426,16 +492,20 @@ final class InvestorContext } $ref = $this->reference($key); $expected = $slot === 'opportunity' ? ['opportunity', 'business'] : [$slot]; - abort_unless(in_array($ref['kind'], $expected, true), 422, 'Referência incompatível com a dimensão.'); + if (! (in_array($ref['kind'], $expected, true))) { + abort(422, 'Referência incompatível com a dimensão.'); + } $selection[$slot] = $ref; } $corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity' - ? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail($selection['opportunity']['id'])) : null); - if ($corridor && isset($selection['territory'])) abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.'); + ? app(InvestmentCorridorContext::class)->key(Opportunity::whereKey($selection['opportunity']['id'])->firstOrFail()) : null); + if ($corridor && isset($selection['territory'])) { + abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.'); + } if ($corridor && isset($selection['park'])) { $eligible = array_column(app(InvestmentCorridorContext::class)->parks($corridor, $selection['territory']['id'] ?? null), 'id'); - if (!in_array($selection['park']['id'], $eligible, true)) { - abort_if(!empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.'); + if (! in_array($selection['park']['id'], $eligible, true)) { + abort_if(! empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.'); unset($selection['park']); } } @@ -484,14 +554,16 @@ final class InvestorContext } } if ($corridor) { - foreach (['province_id','district_id','admin_post_id','locality_id','province','district','location'] as $key) { + foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'] as $key) { if (isset($selection['territory']['fields'][$key])) { - $fields[$key]=(string)$selection['territory']['fields'][$key]; - $provenance[$key]=Arr::except($selection['territory'],['fields']); - } else unset($fields[$key],$provenance[$key]); + $fields[$key] = (string) $selection['territory']['fields'][$key]; + $provenance[$key] = Arr::except($selection['territory'], ['fields']); + } else { + unset($fields[$key],$provenance[$key]); + } } - $fields['special_zone_id']=$selection['park']['id']??''; - $fields['investment_regime']=$selection['park']['fields']['investment_regime']??'general'; + $fields['special_zone_id'] = $selection['park']['id'] ?? ''; + $fields['investment_regime'] = $selection['park']['fields']['investment_regime'] ?? 'general'; } $replaced = []; foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) { @@ -505,11 +577,16 @@ final class InvestorContext } } $changedFields = array_column($replaced, 'field'); - if ($complements !== ($old['complements'] ?? [])) $changed[] = 'complements'; + if ($complements !== ($old['complements'] ?? [])) { + $changed[] = 'complements'; + } $choicesChanged = $choices !== ($old['opportunity_choices'] ?? []); - $choiceInputsChanged = array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$choices) !== array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$old['opportunity_choices']??[]); - if ($choiceInputsChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory'])); - elseif ($choicesChanged) $changed[] = 'portfolio_origins'; + $choiceInputsChanged = array_map(fn ($c) => Arr::except($c, ['portfolio_origins']), $choices) !== array_map(fn ($c) => Arr::except($c, ['portfolio_origins']), $old['opportunity_choices'] ?? []); + if ($choiceInputsChanged) { + $changed = array_values(array_unique([...$changed, 'opportunity', 'territory'])); + } elseif ($choicesChanged) { + $changed[] = 'portfolio_origins'; + } $conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary'])); $dependencyChanged = $choiceInputsChanged || in_array('complements', $changed, true) || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged; $privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS); @@ -524,7 +601,9 @@ final class InvestorContext if ($privateConditionsChanged) { $steps = array_values(array_unique([...$steps, 'S02', 'S05', 'S06', 'S07'])); } - if (in_array('complements', $changed, true)) $steps = array_values(array_unique([...$steps, 'S02', 'S08'])); + if (in_array('complements', $changed, true)) { + $steps = array_values(array_unique([...$steps, 'S02', 'S08'])); + } $privatePreparation = null; if (app(PrivateInvestmentPreparation::class)->available()) { $docs = $this->preparationDocuments($id); @@ -543,6 +622,10 @@ final class InvestorContext return [...$plan, 'preview_hash' => $hash]; } + /** + * @param array $input + * @return array + */ public function commit(User $actor, string $id, array $input): array { abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.'); @@ -586,7 +669,7 @@ final class InvestorContext } } $payload['investor_path']['context'] = $context; - unset($payload['investor_path']['context_drafts'][(string)$actor->id]); + unset($payload['investor_path']['context_drafts'][(string) $actor->id]); DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'title' => $context['fields']['project_title'] ?? $case->title, 'version' => $case->version + 1, 'updated_at' => now()]); $this->desk->event($this->desk->case($actor, $id), $actor, 'investment_context_changed', ['before' => $before, 'after' => $context, 'affected_result_ids' => array_column($plan['dependent_results'], 'id'), 'preserved_request_ids' => array_column($plan['requests'], 'id')]); $result = ['case_id' => $id, 'version' => (int) $case->version + 1, 'revision' => $revision, 'replayed' => false]; @@ -596,6 +679,9 @@ final class InvestorContext }, 3); } + /** + * @return array + */ public function view(User $actor, string $id, ?string $opportunity = null): array { $case = $this->desk->case($actor, $id); @@ -622,21 +708,28 @@ final class InvestorContext $prefill = $this->prefill($ctx, $opportunity); $saved = $this->desk->decode($case->payload); - if (!empty($saved['reuse_consent'])) { - $contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== ''); + if (! empty($saved['reuse_consent'])) { + $contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn ($v) => is_string($v) && $v !== ''); $prefill['simulator'] = [...$contacts, ...$prefill['simulator']]; - $prefill['land'] = [...$contacts, ...(!empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']]; + $prefill['land'] = [...$contacts, ...(! empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']]; } + return ['private_investment' => $preparation, 'preparation_documents' => $this->preparationDocuments($id), 'simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']), 'can_write' => $path['canWrite'], 'next' => $path['next'], 'results' => $path['results'], 'requests' => $path['requests'], 'prefill' => $prefill, 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.']; } + /** + * @return array + */ public function preparationDocuments(string $id): array { return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all(); } + /** + * @return array + */ public function landPreparation(\stdClass $case, ?string $opportunity = null): array { $payload = $this->desk->decode($case->payload); @@ -648,6 +741,10 @@ final class InvestorContext return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $key ? ($path['opportunity_fact_reasons'][$key]['land_needed'] ?? '') : ($path['fact_reasons']['land_needed'] ?? '')]; } + /** + * @param array $ctx + * @return array + */ public function prefill(array $ctx, ?string $opportunity = null): array { $original = $ctx; @@ -659,15 +756,21 @@ final class InvestorContext $sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular']; if (($ctx['land_preparation']['need'] ?? null) === 'yes') { $tenure = ['duat' => ['duat', 'DUAT'], 'lease' => ['leased', 'Arrendado']][$f['site_tenure'] ?? ''] ?? null; - if ($tenure) [$sim['land_status'], $sim['land_status_label']] = $tenure; + if ($tenure) { + [$sim['land_status'], $sim['land_status_label']] = $tenure; + } } // Human labels travel with the exact existing identifiers, not inferred locations. foreach (app(InvestmentGeography::class)->publicReferences()->whereIn('id', array_values(Arr::only($f, array_keys(InvestmentGeography::FIELDS))))->get(['id', 'name']) as $place) { foreach (array_keys(InvestmentGeography::FIELDS) as $key) { - if (($f[$key] ?? null) === $place->id) $sim[$key.'_label'] = $place->name; + if (($f[$key] ?? null) === $place->id) { + $sim[$key.'_label'] = $place->name; + } } } - if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->find($f['sector_id']))) $sim['sector_id_label'] = $sector->name; + if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->whereKey($f['sector_id'])->first())) { + $sim['sector_id_label'] = $sector->name; + } $zone = $ctx['selection']['park'] ?? null; $sim['context_applicability_snapshot'] = json_encode([ 'version' => 1, 'context_revision' => $ctx['revision'], @@ -675,12 +778,13 @@ final class InvestorContext 'portfolio_origin' => $ctx['selection']['portfolio'] ?? null, 'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null, 'provenance' => $ctx['provenance'], - 'opportunity_choices' => $ctx['opportunity_choices'] ?? [], + // Retain dossier options as provenance when no consumer is selected; scoped fields remain empty. + 'opportunity_choices' => ($ctx['opportunity_choices'] ?? []) ?: ($original['opportunity_choices'] ?? []), 'complements' => $ctx['complements'] ?? [], 'requirements_assessment' => $ctx['requirements_assessment'] ?? null, 'path' => $ctx['path_applicability'] ?? null, 'land_preparation' => $ctx['land_preparation'] ?? null, - 'field_provenance' => array_map(fn($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []), + 'field_provenance' => array_map(fn ($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []), 'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null, 'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']), 'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.', @@ -688,6 +792,7 @@ final class InvestorContext $scoped = app(InvestmentConsumerScope::class)->context($original, $opportunity); $f = $scoped['fields']; + return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null), 'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null), 'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)]; diff --git a/app/Services/InvestorContextConsumers.php b/app/Services/InvestorContextConsumers.php index 1abd547f..060500fd 100644 --- a/app/Services/InvestorContextConsumers.php +++ b/app/Services/InvestorContextConsumers.php @@ -20,7 +20,7 @@ final class InvestorContextConsumers } } -return 0; + return 0; } public function landRevision(string $id): int @@ -32,7 +32,7 @@ return 0; } } -return 0; + return 0; } public function submissionRevision(string $id): int @@ -44,9 +44,12 @@ return 0; } } -return 0; + return 0; } + /** + * @param array $input + */ public function assertReviewed(User $actor, string $id, array $input, int $bound): int { $case = app(VirtualDesk::class)->case($actor, $id, true, true); diff --git a/app/Services/InvestorContextInstallation.php b/app/Services/InvestorContextInstallation.php index 2dac047b..125c2b41 100644 --- a/app/Services/InvestorContextInstallation.php +++ b/app/Services/InvestorContextInstallation.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -15,6 +16,9 @@ final class InvestorContextInstallation { public const SOURCE = 'investor-context-installation'; + /** + * @return array + */ public function files(): array { return ['app/Http/Controllers/InvestmentSubmissionController.php', 'app/Http/Controllers/InvestorContextController.php', 'app/Http/Controllers/LandAssistanceController.php', 'app/Http/Controllers/PublicPortalController.php', 'app/Http/Controllers/VirtualDeskController.php', 'app/Http/Requests/StoreLandAssistanceCaseRequest.php', 'app/Services/InvestorContext.php', 'app/Services/InvestorContextConsumers.php', 'app/Services/InvestorContextInstallation.php', 'app/Services/InvestorPath.php', 'app/Services/VirtualDesk.php', 'database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php', 'resources/js/app.tsx', 'resources/js/components/catalogue-navigation.tsx', 'resources/js/components/dossier-navigation-context.tsx', 'resources/js/components/dossier-prefill.tsx', 'resources/js/components/navigation-context.tsx', 'resources/js/components/portal-grouped-menu.tsx', 'resources/js/components/simulation-form.tsx', 'resources/js/layouts/app/app-sidebar-layout.tsx', 'resources/js/layouts/portal-navigation.tsx', 'resources/js/lib/investor-context.ts', 'resources/js/lib/investor-path.ts', 'resources/js/lib/portal-navigation.ts', 'resources/js/pages/desk/request.tsx', 'resources/js/pages/investment/business-opportunity.tsx', 'resources/js/pages/investment/catalogue.tsx', 'resources/js/pages/investment/detail.tsx', 'resources/js/pages/investment/submission.tsx', 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investor-path/context-installation.tsx', 'resources/js/pages/investor-path/context-start.tsx', 'resources/js/pages/investor-path/context.tsx', 'resources/js/pages/investor-path/show.tsx', 'resources/js/pages/land-assistance/form.tsx', 'resources/js/pages/portal/services.tsx', 'resources/js/pages/portal/why-invest.tsx', 'routes/investment.php', 'routes/investor-path.php', 'routes/web.php']; @@ -41,6 +45,9 @@ final class InvestorContextInstallation } } + /** + * @return array + */ public function preview(User $actor): array { $this->authorize($actor); @@ -60,12 +67,12 @@ final class InvestorContextInstallation } $isolated = app()->environment('testing') && config('database.connections.pgsql.host') === '127.0.0.1' && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') && ! config('database.connections.pgsql.url'); $proofPath = storage_path('app/private/investmoz-v2/investor-context/readiness.json'); - $proof = is_file($proofPath) ? json_decode(file_get_contents($proofPath), true) : []; + $proof = is_file($proofPath) ? json_decode(CheckedFile::read($proofPath), true) : []; $fresh = ! app()->environment('testing') && ($proof['code_hash'] ?? '') === $code && ($proof['target'] ?? []) === $target && ($proof['authorization_record'] ?? '') !== '' && ($proof['migration'] ?? '') === InvestorContext::MIGRATION && ($proof['restore_verified'] ?? false) && ($proof['restored_boot_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time(); foreach (['dump', 'files'] as $kind) { $p = $proof[$kind.'_path'] ?? ''; - $fresh = $fresh && is_file($p) && hash_equals($proof[$kind.'_sha256'] ?? '', hash_file('sha256', $p)); + $fresh = $fresh && is_file($p) && hash_equals($proof[$kind.'_sha256'] ?? '', CheckedFile::sha256($p)); } if (! $isolated && ! $fresh) { $reasons[] = 'Autorização específica, hashes e backup/restauro/arranque frescos desta candidata necessários.'; @@ -75,10 +82,14 @@ final class InvestorContextInstallation return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))]; } + /** + * @param array $input + * @return array + */ public function apply(User $actor, array $input): array { $this->authorize($actor, true); - abort_if(array_diff(array_keys($input), ['hash', 'operation_key', 'confirm', '_token']), 422, 'Migration, ator ou caminho arbitrário recusado.'); + abort_if(array_diff(array_keys($input), ['hash', 'operation_key', 'confirm', '_token']) !== [], 422, 'Migration, ator ou caminho arbitrário recusado.'); $data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate(); return DB::transaction(function () use ($actor, $data) { diff --git a/app/Services/InvestorPath.php b/app/Services/InvestorPath.php index e3881da5..1f73701e 100644 --- a/app/Services/InvestorPath.php +++ b/app/Services/InvestorPath.php @@ -6,6 +6,7 @@ use App\Models\CrmTask; use App\Models\LandAssistanceCase; use App\Models\SimulationFormDraft; use App\Models\User; +use App\Support\StructuredData; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -152,7 +153,7 @@ final class InvestorPath $landCase = (int) $case->user_id === $actor->id ? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get() ->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id - && (!$allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null; + && (! $allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null; $preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null; if ($preparation && $preparation['enabled']) { @@ -163,7 +164,7 @@ final class InvestorPath 'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'], 'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons, 'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices, - 'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect($path['sector_contexts'] ?? [])->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect($path['content_sources'] ?? [])->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null, + 'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect(StructuredData::collectionItems($path['sector_contexts'] ?? []))->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect(StructuredData::collectionItems($path['content_sources'] ?? []))->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null, 'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at, 'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null, 'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null, @@ -256,8 +257,11 @@ final class InvestorPath abort_if($state === 'completed' && ($step['context_review_required'] ?? false) && (! (bool) ($data['context_reviewed'] ?? false) || ($data['context_revision'] ?? -1) !== ($view['investment_context']['revision'] ?? 0)), 422, 'O contexto desta etapa exige nova conferência fundamentada; o registo anterior permanece no histórico.'); $detail = [...$data, 'context_dependency_hash' => $view['investment_context']['dependency_hash'] ?? null, 'fintech_fingerprint' => $view['fintech']['review_fingerprints'][$code] ?? null, 'scope' => $scope, 'actor_id' => $actor->id, 'recorded_at' => now()->toIso8601String()]; $stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail')); - if ($key) $stored['opportunities'][$key] = $detail; - else $stored = [...$stored, ...$detail]; + if ($key) { + $stored['opportunities'][$key] = $detail; + } else { + $stored = [...$stored, ...$detail]; + } DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored), 'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'), 'version_no' => $step['version'] + 1, 'updated_at' => now()]); diff --git a/app/Services/LegacyContent.php b/app/Services/LegacyContent.php index 7a390457..ebe1ca48 100644 --- a/app/Services/LegacyContent.php +++ b/app/Services/LegacyContent.php @@ -5,13 +5,18 @@ namespace App\Services; use App\Models\Opportunity; use App\Models\Sector; use App\Models\User; +use App\Support\CheckedPattern; use DOMDocument; use DOMElement; use DOMNode; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpKernel\Exception\HttpException; /** A reading adapter over the existing library, never a second content/opportunity register. */ final class LegacyContent @@ -68,13 +73,13 @@ final class LegacyContent $opportunities = Opportunity::published()->whereIn('sector_id', $relatedSectors->pluck('id'))->latest('published_at')->limit(12)->get(['id', 'title', 'slug', 'province', 'summary']); } $localHtml = $this->localLinks($this->sanitize($source['content_html'])); - $editorial = \Illuminate\Support\Facades\Schema::hasColumn('portal_resources', 'editorial_content') + $editorial = Schema::hasColumn('portal_resources', 'editorial_content') ? DB::table('portal_resources')->where('id', $row->resource_id)->value('editorial_content') : null; if ($editorial) { $adaptation = json_decode($editorial, true, 64, JSON_THROW_ON_ERROR); - $localHtml = implode('', array_map(fn ($p) => '

'.nl2br(e($p)).'

', preg_split('/\n\s*\n/', $adaptation['body']))); + $localHtml = implode('', array_map(fn ($p) => '

'.nl2br(e($p)).'

', CheckedPattern::split('/\n\s*\n/', $adaptation['body']))); $source['title'] = $row->title; - if (!empty($adaptation['image_resource_id'])) { + if (! empty($adaptation['image_resource_id'])) { $image = $this->publicResources()->where('r.id', $adaptation['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->first(); } } @@ -86,11 +91,15 @@ final class LegacyContent $relatedArticles = []; if ($includeRelated) { foreach ($this->related($identity['group'], $slug) as $item) { - if (in_array($item['href'], $alreadyLinked, true)) continue; + if (in_array($item['href'], $alreadyLinked, true)) { + continue; + } try { $relatedArticles[] = $this->article($item['slug'], false); - } catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { - if ($error->getStatusCode() !== 404) throw $error; + } catch (HttpException $error) { + if ($error->getStatusCode() !== 404) { + throw $error; + } } } } @@ -125,16 +134,21 @@ final class LegacyContent && str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) { return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.'; } - preg_match_all('~]*>(.*?)

~isu', $html, $paragraphs); - foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) { - $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); - if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320); + if (preg_match_all('~]*>(.*?)

~isu', $html, $paragraphs) === false) { + throw new \RuntimeException('Invalid paragraph encoding; original preserved.'); } + foreach ([...$paragraphs[1], $html] as $paragraph) { + $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); + if (mb_strlen($text) >= 60) { + return Str::limit($text, 320); + } + } + return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } - /** Resolve a library identity, preserving the already stored source URLs and versions. */ - /** @return array */ + /** Resolve a library identity, preserving the already stored source URLs and versions. + * @return array */ public function articleForResource(int $resourceId): array { abort_unless($this->available(), 404); @@ -239,8 +253,10 @@ final class LegacyContent }); } - /** Resolve explicit transport aliases without changing original IDs, versions or relations. */ - private function identityResources(): \Illuminate\Support\Collection + /** Resolve explicit transport aliases without changing original IDs, versions or relations. + * @return Collection + */ + private function identityResources(): Collection { $identities = array_column(config('legacy_content.articles', []), 'external_id'); $aliases = config('legacy_content_aliases', []); @@ -269,6 +285,7 @@ final class LegacyContent $row->legacy_identity = $identity; $resolved->put($identity, $row); } + return $resolved; } diff --git a/app/Services/OpportunityCanonicalContract.php b/app/Services/OpportunityCanonicalContract.php index 7b0d4674..04efdc1f 100644 --- a/app/Services/OpportunityCanonicalContract.php +++ b/app/Services/OpportunityCanonicalContract.php @@ -17,7 +17,10 @@ class OpportunityCanonicalContract { public const BASE_CATEGORIES = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise']; - /** Availability of this bounded base-category delivery, without rewriting existing contracts/data. */ + /** Availability of this bounded base-category delivery, without rewriting existing contracts/data. + * @param array $payload + * @return array + */ public function deliveryBlockers(array $payload): array { $errors = []; @@ -44,7 +47,10 @@ class OpportunityCanonicalContract public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1'; - /** @return array */ + /** + * @param array $payload + * @return array + */ public function schema(bool $draft = false, array $payload = []): array { $bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json')); @@ -173,6 +179,7 @@ class OpportunityCanonicalContract $args = $node->args(); if ($node->keyword() === 'type' && in_array($prefix, ['canonical.initiative.responsible_name', 'canonical.initiative.coverage_statement'], true)) { $errors[$prefix][] = 'Informação em falta: preencha este campo antes de submeter.'; + return; } @@ -298,7 +305,7 @@ class OpportunityCanonicalContract && hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.'); if ($file) { // Verify actual bytes/cipher integrity as well as the recorded scan hash. - app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($row, $file); + app(OpportunityLibrarySupport::class)->assertAvailable($row, $file); app(VerifiedDocumentBinary::class)->respond($file); $row->sha256 = trim($file->sha256); } diff --git a/app/Services/OpportunityClassification.php b/app/Services/OpportunityClassification.php index 810aa8da..8e11dc1f 100644 --- a/app/Services/OpportunityClassification.php +++ b/app/Services/OpportunityClassification.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Http\Controllers\InvestmentPortfolioController; use App\Models\Opportunity; use App\Models\User; +use App\Support\StructuredData; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -40,6 +41,10 @@ class OpportunityClassification public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired']; + /** + * @param array $schema + * @return array + */ public function extend(array $schema): array { $fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)], @@ -86,7 +91,8 @@ class OpportunityClassification $query->whereIn('cluster_id', $this->targets('cluster')->select('id')); } if ($kind === 'location') { - $query->whereIn('id', app(InvestmentGeography::class)->publicReferences()->select('id')); + // Use the authoritative public scope directly; wrapping it in a second scan multiplies recursive ancestry checks. + $query = app(InvestmentGeography::class)->publicReferences(); } if (in_array($kind, ['zone', 'park'], true)) { return $query->whereIn('id', app(InvestmentZones::class)->visible()->select('id'))->when($kind === 'park', fn ($q) => $q->where('category', 'industrial_park')); @@ -100,6 +106,9 @@ class OpportunityClassification return app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind); } + /** + * @return array + */ public function options(): array { $targets = []; @@ -112,6 +121,9 @@ class OpportunityClassification return ['version' => self::VERSION, 'dimensions' => self::DIMENSIONS, 'types' => array_map(fn ($v) => $v[2], self::TARGETS), 'purposes' => self::PURPOSES, 'states' => self::STATES, 'externalStates' => self::EXTERNAL, 'targets' => $targets]; } + /** + * @param array $payload + */ public function validate(array $payload, Opportunity $record): void { $previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : []; @@ -185,16 +197,26 @@ class OpportunityClassification } } - /** Exact persisted relation retention is not permission to choose a withdrawn target anew. */ + /** Exact persisted relation retention is not permission to choose a withdrawn target anew. + * @param array $previousRows + * @param array $row + */ public function canRetainReference(string $kind, string $id, array $row, array $previousRows): bool { - if ($this->targets($kind)->where('id', $id)->exists()) { return true; } - if (! app(EconomicReferenceCatalog::class)->historicalTarget($kind, $id)) { return false; } + if ($this->targets($kind)->where('id', $id)->exists()) { + return true; + } + if (! app(EconomicReferenceCatalog::class)->historicalTarget($kind, $id)) { + return false; + } $content = fn ($r) => array_intersect_key($r, array_flip(['related_id', 'relation_type', 'classification'])); return collect($previousRows)->contains(fn ($old) => OpportunityCanonicalValue::hash($content($old)) === OpportunityCanonicalValue::hash($content($row))); } + /** + * @param array $row + */ public function hash(array $row): string { // Client identity/status cannot certify a relation. Object-order normalization is reused. @@ -212,6 +234,9 @@ class OpportunityClassification && ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true)); } + /** + * @param array $row + */ public function state(Opportunity $record, array $row): string { $decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null; @@ -219,7 +244,7 @@ class OpportunityClassification return $decision['state']; } if ($decision) { - $reviewer = User::find($decision['actor_id']); + $reviewer = User::whereKey($decision['actor_id'])->first(); $c = $row['classification']; if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) { return 'in_validation'; @@ -239,6 +264,9 @@ class OpportunityClassification return $row['classification']['requested_state'] ?? 'preliminary'; } + /** + * @param array $row + */ private function evidenceHash(array $row): ?string { $id = $row['classification']['document_id'] ?? null; @@ -251,6 +279,9 @@ class OpportunityClassification return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]); } + /** + * @return array + */ public function listing(User $actor, Opportunity $record): array { app(PortalProfileAccess::class)->authorize($actor, 'view', $record); @@ -271,11 +302,14 @@ class OpportunityClassification } return ['hash' => $this->hash($row), 'dimension' => $dimension, 'relation' => $row, 'state' => $this->state($record, $row), - 'metadata' => collect($metadata['active'] ?? [])->first(fn ($m) => ($m['fingerprint'] ?? '') === $this->hash($row)), 'previous_versions' => $prior, + 'metadata' => collect(StructuredData::collectionItems($metadata['active'] ?? []))->first(fn ($m) => ($m['fingerprint'] ?? '') === $this->hash($row)), 'previous_versions' => $prior, 'can_review' => $this->canReview($actor, $record, $dimension), 'history' => $record->workflow_context['classification_decisions'][$this->hash($row)]['history'] ?? []]; }, $rows); } + /** + * @param array $input + */ public function review(User $actor, Opportunity $record, array $input): Opportunity { $data = Validator::make($input, ['etag' => 'required|string|size:64', 'hash' => 'required|string|size:64', 'state' => 'required|in:in_validation,confirmed,rejected,archived,revoked', 'reason' => 'required|string|min:20|max:1000'])->validate(); @@ -291,7 +325,7 @@ class OpportunityClassification if (in_array($data['state'], ['revoked', 'archived'], true)) { $rows = [...$rows, ...($record->workflow_context['publication']['classification'] ?? [])]; } - $row = collect($rows)->first(fn ($r) => isset($r['classification']) && $this->hash($r) === $data['hash']); + $row = collect(StructuredData::collectionItems($rows))->first(fn ($r) => isset($r['classification']) && $this->hash($r) === $data['hash']); abort_unless($row, 409, 'Associação inexistente nesta versão.'); $c = $row['classification']; abort_unless($this->canReview($actor, $record, self::TARGETS[$c['target_type']][2]), 403, 'Revisão exige âmbito, responsabilidade, MFA e independência aprovados.'); @@ -314,12 +348,18 @@ class OpportunityClassification }); } - /** Frozen at the existing editorial publication, not on draft save or association confirmation. */ + /** Frozen at the existing editorial publication, not on draft save or association confirmation. + * @param array $payload + * @return array + */ public function publication(array $payload): array { return array_values(array_filter($payload['relations'] ?? [], fn ($r) => isset($r['classification']) && ($r['classification']['disclosure'] ?? 'restricted') === 'public')); } + /** + * @return array + */ public function publicRows(Opportunity $record): array { if ($record->trashed() || $record->status !== 'published' || ! $record->published_at || $record->published_at->isFuture() || ($record->workflow_context['publication']['visibility'] ?? '') !== 'public') { @@ -343,7 +383,7 @@ class OpportunityClassification // Original documents, internal rationale, actor IDs and decision history stay private. $rows[] = ['dimension' => self::TARGETS[$kind][2], 'target_type' => $kind, 'target_id' => (string) $row['related_id'], 'label' => $target->{self::TARGETS[$kind][1]}, 'purpose' => $c['purpose'], 'purpose_label' => self::PURPOSES[$c['purpose']], 'target_code' => $target->code ?? null, 'catalog_version' => $target->catalog_version_id ?? null, - 'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)?->path ?? []) : [], + 'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)->path ?? []) : [], 'physical_category' => $target->physical_category ?? null, 'legal_regime' => $target->legal_regime ?? null, 'state' => $state, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null, 'authority_id' => $c['authority_id'] ?? null, 'external_status' => $c['external_status'] ?? 'not_assessed', 'availability_confirmed' => false, @@ -353,7 +393,10 @@ class OpportunityClassification return $rows; } - /** Same public projection for count/list/map; AND across filters, OR across selected associations. */ + /** Same public projection for count/list/map; AND across filters, OR across selected associations. + * @param array $filters + * @param Builder $query + */ public function constrain(Builder $query, array $filters): void { $keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id']; @@ -383,7 +426,7 @@ class OpportunityClassification $matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']); } if (! empty($filters['subsector_id'])) { - $matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id','economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id'])); + $matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id', 'economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id'])); } if ($matches) { $ids[] = $record->id; @@ -393,9 +436,13 @@ class OpportunityClassification $query->whereIn('opportunities.id', $ids); } + /** + * @return array + */ public function duplicates(User $actor, Opportunity $record): array { app(PortalProfileAccess::class)->authorize($actor, 'view', $record); + // Exact-title candidates only within the actor's existing visibility; never merge or reveal another dossier. return app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->whereKeyNot($record->id) ->whereRaw('lower(trim(title))=lower(trim(?))', [$record->title])->limit(10)->get(['id', 'reference', 'title']) diff --git a/app/Services/OpportunityContacts.php b/app/Services/OpportunityContacts.php index 46999424..6e5c86dc 100644 --- a/app/Services/OpportunityContacts.php +++ b/app/Services/OpportunityContacts.php @@ -14,6 +14,10 @@ use Illuminate\Validation\ValidationException; /** Reuse the CRM master without manufacturing an interest, lead or consent. */ class OpportunityContacts { + /** + * @param array $input + * @return array + */ public function save(User $actor, int $id, array $input): array { $access = app(PortalProfileAccess::class); @@ -63,7 +67,7 @@ class OpportunityContacts $investors = Investor::where('organization_id', $record->organization_id)->whereIn('status', ['prospect', 'active'])->lockForUpdate()->get(); if (! empty($data['investor_id'])) { $investor = $investors->firstWhere('id', $data['investor_id']); - abort_unless($investor, 403, 'O cadastro selecionado não pertence ao promotor desta oportunidade.'); + abort_unless($investor !== null, 403, 'O cadastro selecionado não pertence ao promotor desta oportunidade.'); } elseif ($investors->count() === 1) { $investor = $investors->first(); } elseif ($investors->count() > 1) { @@ -73,7 +77,7 @@ class OpportunityContacts throw ValidationException::withMessages(['investor_type' => 'Indique a classificação do promotor no cadastro existente.']); } $organization = DB::table('organizations')->where('id', $record->organization_id)->whereNull('deleted_at')->first(); - abort_unless($organization, 422, 'Organização indisponível.'); + abort_unless($organization !== null, 422, 'Organização indisponível.'); $investor = new Investor; PortalAudit::save($investor, ['organization_id' => $organization->id, 'legal_name' => $organization->name, 'investor_type' => $data['investor_type'], 'status' => 'prospect', 'source_system' => 'portal-promoter-master', diff --git a/app/Services/OpportunityCorridorExplorer.php b/app/Services/OpportunityCorridorExplorer.php index 432222fe..0d24603c 100644 --- a/app/Services/OpportunityCorridorExplorer.php +++ b/app/Services/OpportunityCorridorExplorer.php @@ -2,11 +2,15 @@ namespace App\Services; -use App\Models\Opportunity; +use App\Support\StructuredData; /** Read-only grouping of the existing public catalogue. */ final class OpportunityCorridorExplorer { + /** + * @param array $filters + * @return array + */ public function groups(array $filters): array { $catalogue = app(InvestmentCatalogue::class); @@ -14,7 +18,7 @@ final class OpportunityCorridorExplorer foreach ($catalogue->query($filters)->get() as $record) { $context = $catalogue->context($record->id); $classification = $context['classification'] ?? []; - $corridors = collect($context['corridors'])->mapWithKeys(fn ($c) => ['corridor:'.$c->id => $c->name])->all(); + $corridors = collect(StructuredData::collectionItems($context['corridors']))->mapWithKeys(fn ($c) => ['corridor:'.$c->id => $c->name])->all(); foreach ($classification as $relation) { if ($relation['target_type'] === 'corridor' && ! in_array($relation['purpose'], ['proximity', 'administrative_boundary'], true)) { $corridors['corridor:'.$relation['target_id']] = $relation['label']; @@ -27,12 +31,16 @@ final class OpportunityCorridorExplorer $corridors['source:'.hash('sha256', $source['corridor'])] = $source['corridor']; $chain = $source['chain']; } - $chains = collect($classification)->where('target_type', 'chain')->pluck('label')->all(); - if ($chain) $chains[] = $chain; - $stages = collect($classification)->pluck('production_stage')->filter()->unique()->values()->all(); + $chains = collect(StructuredData::collectionItems($classification))->where('target_type', 'chain')->pluck('label')->all(); + if ($chain) { + $chains[] = $chain; + } + $stages = collect(StructuredData::collectionItems($classification))->pluck('production_stage')->filter()->unique()->values()->all(); // Keep the source's actual activity description rather than inventing stage labels. - if ($stages === [] && $chain && ! empty($source['summary'])) $stages = [$source['summary']]; - $path = collect($context['primary_territory_path'] ?? []); + if ($stages === [] && $chain && ! empty($source['summary'])) { + $stages = [$source['summary']]; + } + $path = collect(StructuredData::collectionItems($context['primary_territory_path'] ?? [])); $province = $path->firstWhere('location_type', 'province')->name ?? $record->province; $district = $path->firstWhere('location_type', 'district')->name ?? $record->district; $contextProvinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record); @@ -53,6 +61,7 @@ final class OpportunityCorridorExplorer } unset($group); uasort($groups, fn ($a, $b) => ($a['id'] === 'other' ? 1 : 0) <=> ($b['id'] === 'other' ? 1 : 0) ?: strnatcasecmp($a['name'], $b['name'])); + return array_values($groups); } } diff --git a/app/Services/OpportunityImageProcessor.php b/app/Services/OpportunityImageProcessor.php index 5c1e2877..25fbd48d 100644 --- a/app/Services/OpportunityImageProcessor.php +++ b/app/Services/OpportunityImageProcessor.php @@ -45,7 +45,7 @@ class OpportunityImageProcessor $previousDerivatives = $previousPayload['derivatives'] ?? []; $expectedWidths = array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280])); if (($previousPayload['derived_from_sha256'] ?? null) === trim($doc->sha256) - && array_values(array_column($previousDerivatives, 'width')) === array_values($expectedWidths)) { + && array_column($previousDerivatives, 'width') === array_values($expectedWidths)) { $reusable = true; $retry = []; foreach ($previousDerivatives as $derivative) { @@ -65,6 +65,7 @@ class OpportunityImageProcessor } if ($reusable) { $documents = $retry; + return; // Retry scans of the same streams, without new files, versions or audit decisions. } } diff --git a/app/Services/OpportunityInitiativeApplicability.php b/app/Services/OpportunityInitiativeApplicability.php index bf6f015f..8be21334 100644 --- a/app/Services/OpportunityInitiativeApplicability.php +++ b/app/Services/OpportunityInitiativeApplicability.php @@ -11,17 +11,28 @@ class OpportunityInitiativeApplicability 'unit_investment' => ['total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators'], ]; + /** + * @param array $p + */ public function enabled(array $p): bool { return ($p['opportunity_category'] ?? null) === 'investment_project' && in_array($p['initiative']['kind'] ?? null, ['program', 'portfolio'], true); } + /** + * @param array $p + */ public function applicable(array $p, string $group): bool { return ! $this->enabled($p) || ($p['initiative']['applicability'][$group] ?? 'missing') !== 'not_applicable'; } + /** + * @param array $payload + * @param array $schema + * @return array + */ public function schema(array $schema, array $payload): array { $base = &$schema['$defs']['opportunityCreate']; @@ -42,6 +53,7 @@ class OpportunityInitiativeApplicability if (isset($payload['initiative'])) { $base['allOf'][] = ['properties' => ['opportunity_category' => ['const' => 'investment_project']]]; } + return $schema; } $ignored = []; @@ -73,9 +85,14 @@ class OpportunityInitiativeApplicability if (! $this->applicable($payload, 'physical_site')) { unset($base['properties']['locations']['minItems']); } + return $schema; } + /** + * @param array $errors + * @param array $p + */ public function validate(array $p, array &$errors): void { if (! $this->enabled($p)) { @@ -91,4 +108,3 @@ class OpportunityInitiativeApplicability } } } - diff --git a/app/Services/OpportunityLibrarySupport.php b/app/Services/OpportunityLibrarySupport.php index 12a0c00d..2c3e91d4 100644 --- a/app/Services/OpportunityLibrarySupport.php +++ b/app/Services/OpportunityLibrarySupport.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Opportunity; use App\Models\User; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -11,12 +12,15 @@ use Illuminate\Support\Facades\Validator; /** An existing opportunity resource references the original document. No binary or permission is copied. */ class OpportunityLibrarySupport { + /** + * @return array + */ public function choices(User $actor): array { return $this->sources($actor)->orderBy('r.id')->limit(100)->get(['r.id', 'r.title', 'r.version_label', 'r.metadata_version', 'r.published', 'd.sha256'])->all(); } - private function sources(User $actor) + private function sources(User $actor): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') @@ -24,18 +28,21 @@ class OpportunityLibrarySupport ->whereIn('d.data_classification', ['internal', 'public']) ->when(! $actor->can('manage-portal'), function ($query) use ($actor) { $query->where(function ($scope) use ($actor) { - $scope->where(fn ($public) => $public->where('r.published',true)->where('d.data_classification','public')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256')); + $scope->where(fn ($public) => $public->where('r.published', true)->where('d.data_classification', 'public')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')); if ($actor->hasVerifiedEmail()) { - $scope->orWhere(fn ($own) => $own->where('r.created_by',$actor->id)->where('d.uploaded_by',$actor->id)); + $scope->orWhere(fn ($own) => $own->where('r.created_by', $actor->id)->where('d.uploaded_by', $actor->id)); } - $editorial=app(DocumentManagementAccess::class); + $editorial = app(DocumentManagementAccess::class); if ($editorial->editorial($actor)) { - $scope->orWhereIn('r.id',$editorial->editorialResources($actor)->select('r.id')); + $scope->orWhereIn('r.id', $editorial->editorialResources($actor)->select('r.id')); } }); }); } + /** + * @param array $input + */ public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass { app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); @@ -45,12 +52,13 @@ class OpportunityLibrarySupport $opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail(); app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); $source = $this->sources($actor)->where('r.id', $data['resource_id'])->lockForUpdate()->first(['r.*', 'd.id as original_id', 'd.sha256', 'd.version_no']); - abort_unless($source, 403, 'O acesso actual à biblioteca não permite reutilizar este original.'); + abort_unless($source !== null, 403, 'O acesso actual à biblioteca não permite reutilizar este original.'); $existing = DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->where('document_id', $source->original_id)->first(); if ($existing) { $payload = json_decode($existing->payload, true); abort_unless(($payload['library_source']['resource_id'] ?? null) === $source->id, 409, 'Original já associado por outro percurso; recuperar o suporte existente.'); abort_unless(($payload['section'] ?? null) === ($data['section'] ?? null) && ($payload['supported_statement'] ?? null) === ($data['supported_statement'] ?? null), 409, 'Original já associado com outra referência; editar explicitamente o suporte existente.'); + return $existing; } abort_unless(hash_equals(app(OpportunityManagement::class)->etag($opportunity), $data['etag']), 409, 'A ficha mudou; preserve a selecção e recupere a versão actual.'); @@ -71,6 +79,7 @@ class OpportunityLibrarySupport 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]); $opportunity->management_version++; $opportunity->save(); + return $after; }); } @@ -79,19 +88,28 @@ class OpportunityLibrarySupport { $payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload; $pin = $payload['library_source'] ?? null; - if (! $pin) return; + if (! $pin) { + return; + } $source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first(); abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted' && in_array($document->data_classification, ['internal', 'public'], true) && $document->id === $pin['document_id'] && hash_equals($pin['sha256'], trim($document->sha256)), 403, 'O original está retirado, restrito ou já não corresponde à versão guardada.'); } + /** + * @return array + */ public function snapshot(User $actor, Opportunity $opportunity): array { $access = app(PortalProfileAccess::class); - if (! $access->canView($actor, $opportunity)) return []; + if (! $access->canView($actor, $opportunity)) { + return []; + } + return collect(app(OpportunityResources::class)->listing($opportunity->id, 'documents'))->filter(fn ($r) => ! $r->deleted_at && isset($r->payload['library_source']))->map(function ($r) use ($opportunity) { $pin = $r->payload['library_source']; + return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version, 'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id, 'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']]; diff --git a/app/Services/OpportunityManagement.php b/app/Services/OpportunityManagement.php index 04dde073..2fc0ee00 100644 --- a/app/Services/OpportunityManagement.php +++ b/app/Services/OpportunityManagement.php @@ -63,7 +63,7 @@ class OpportunityManagement abort_if(array_key_exists('organization_id', $input) && $input['organization_id'] !== $existingRecord->organization_id, 403, 'A organização não pode ser alterada por edição.'); } } elseif ($profileCreation) { - abort_unless(($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? '') || (isset($input['proactive']) && empty($input['organization_id']) && !$access->has($actor, 'auditor') && $access->internalAssignment($actor, $input['opportunity_category'] ?? '') !== null)), 403); + abort_unless(($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? '') || (isset($input['proactive']) && empty($input['organization_id']) && ! $access->has($actor, 'auditor') && $access->internalAssignment($actor, $input['opportunity_category'] ?? '') !== null)), 403); } else { abort_unless($access->legacyWriteAllowed($actor), 403, 'O papel v1 não usa a criação legada para contornar o seu âmbito.'); } @@ -128,7 +128,9 @@ class OpportunityManagement $this->check($record, $data['etag']); abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.'); } - if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor); + if (array_key_exists('proactive', $data)) { + $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor); + } $before = $record->getAttributes(); $previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : []; $changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])); @@ -172,12 +174,12 @@ class OpportunityManagement $record->management_version = 1; $record->estimated_investment_currency = null; if ($profileCreation) { - abort_unless(($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? '') || (isset($data['proactive']) && empty($data['organization_id']) && !$access->has($actor, 'auditor') && $access->internalAssignment($actor, $data['opportunity_category'] ?? '') !== null)), 403); + abort_unless(($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? '') || (isset($data['proactive']) && empty($data['organization_id']) && ! $access->has($actor, 'auditor') && $access->internalAssignment($actor, $data['opportunity_category'] ?? '') !== null)), 403); $record->owner_user_id = $actor->id; $record->organization_id = $data['organization_id'] ?? null; $record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]]; - if (($internal=$access->internalAssignment($actor,$data['opportunity_category'] ?? '')) !== null) { - $record->workflow_context=[...$record->workflow_context,'operator_organization_id'=>$internal->organization_id,'internal_assignment_id'=>$internal->id]; + if (($internal = $access->internalAssignment($actor, $data['opportunity_category'] ?? '')) !== null) { + $record->workflow_context = [...$record->workflow_context, 'operator_organization_id' => $internal->organization_id, 'internal_assignment_id' => $internal->id]; } } } else { @@ -227,7 +229,9 @@ class OpportunityManagement $record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail(); abort_if($record->owner_user_id !== null, 403, 'A v1 exige pedido e decisão de arquivo; não concede exclusão ou restauro ao Promotor.'); $this->check($record, $data['etag']); - if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor); + if (array_key_exists('proactive', $data)) { + $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor); + } $before = $record->getAttributes(); if ($data['action'] === 'restore') { abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.'); diff --git a/app/Services/OpportunityMaturity.php b/app/Services/OpportunityMaturity.php index e34975c5..d139a771 100644 --- a/app/Services/OpportunityMaturity.php +++ b/app/Services/OpportunityMaturity.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\Opportunity; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Validation\ValidationException; /** Catalogue versions are not interchangeable: historical positions keep their meaning. */ @@ -24,7 +26,9 @@ final class OpportunityMaturity public const LEGACY_LABELS = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação']; - /** Public selection is explicit; historical numbers never stand for current stages. */ + /** Public selection is explicit; historical numbers never stand for current stages. + * @return array + */ public static function searchOptions(): array { $options = []; @@ -34,10 +38,14 @@ final class OpportunityMaturity foreach (self::LEGACY_LABELS as $level => $label) { $options[] = ['value' => 'historical:'.$level, 'label' => $label.' — registo histórico', 'group' => 'Classificações históricas']; } + return $options; } - public static function constrainSearch(\Illuminate\Database\Eloquent\Builder $query, string $selection): void + /** + * @param Builder $query + */ + public static function constrainSearch(Builder $query, string $selection): void { [$kind, $value] = explode(':', $selection, 2); if ($kind === 'stage') { diff --git a/app/Services/OpportunityPromotion.php b/app/Services/OpportunityPromotion.php index ff71d6ba..2c875a95 100644 --- a/app/Services/OpportunityPromotion.php +++ b/app/Services/OpportunityPromotion.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Models\Opportunity; +use App\Models\Sector; use App\Models\User; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -14,16 +16,22 @@ use Illuminate\Validation\Rule; final class OpportunityPromotion { public const PURPOSE = 'promotional_preparation'; + public const NOTICE = 'Em preparação. A divulgação não confirma financiamento, disponibilidade de terreno, licenças ou autorização de execução do investimento.'; public function allowed(User $actor, Opportunity $record): bool { $access = app(PortalProfileAccess::class); if ($record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record) - || $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) return false; + || $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) { + return false; + } // Never overwrite a normal approved publication or its workflow decisions. - if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) return false; + if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) { + return false; + } $internal = $access->internalAssignment($actor, $record->opportunity_category); + return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id)) || ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id)); @@ -33,10 +41,13 @@ final class OpportunityPromotion { abort_unless($this->allowed($actor, $record), 403, 'A designação e o âmbito existentes não autorizam esta divulgação.'); if (! app(PortalProfileAccess::class)->mfa($actor)) { - throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required')); + throw new HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required')); } } + /** + * @return array + */ public function editor(User $actor, Opportunity $record): array { abort_unless($this->allowed($actor, $record), 403); @@ -47,15 +58,20 @@ final class OpportunityPromotion 'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '', 'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [], 'sector_id' => $record->sector_id]; + return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record), 'proactiveRelations' => app(ProactiveInvestment::class)->publicationRelations($record), 'proactiveKind' => app(OpportunityManagement::class)->values($record)['proactive']['kind'] ?? null, 'proactiveEnabled' => isset(app(OpportunityManagement::class)->values($record)['proactive']), 'proactiveEvidence' => array_values(array_filter(app(OpportunityManagement::class)->values($record)['proactive']['evidence'] ?? [], fn ($e) => $e['access'] === 'public')), 'proactiveOptions' => app(ProactiveInvestment::class)->options(), 'initial' => [...$form, ...($saved['input']['proactive_public'] ?? null ? ['proactive_public' => $saved['input']['proactive_public']] : [])], 'publicNow' => $existing && $record->status === 'published', 'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor), - 'sectors' => \App\Models\Sector::catalog()->orderBy('name')->get(['id', 'name']), + 'sectors' => Sector::catalog()->orderBy('name')->get(['id', 'name']), 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']), 'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication') ->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])]; } + /** + * @param array $input + * @return array + */ public function preview(User $actor, Opportunity $record, array $input): array { $this->authorize($actor, $record); @@ -64,11 +80,13 @@ final class OpportunityPromotion 'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500', 'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d', 'proactive_public' => 'sometimes|array', 'proactive_reviewed' => 'sometimes|boolean', - 'sector_id' => ['nullable', 'integer', Rule::in(\App\Models\Sector::catalog()->pluck('id')->all())], + 'sector_id' => ['nullable', 'integer', Rule::in(Sector::catalog()->pluck('id')->all())], 'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct', ])->validate(); abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.'); - if ($data['action'] === 'withdraw') abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409); + if ($data['action'] === 'withdraw') { + abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409); + } $paths = []; foreach ($data['territory_ids'] ?? [] as $id) { $path = app(InvestmentGeography::class)->path($id, true); @@ -84,22 +102,38 @@ final class OpportunityPromotion $projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body, 'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE, 'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)]; - if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) { $projection['proactive'] = $proactive; $projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]); } - if ($data['action'] === 'disclose') abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.'); - if ($data['action'] === 'withdraw') $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])]; + if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) { + $projection['proactive'] = $proactive; + $projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]); + } + if ($data['action'] === 'disclose') { + abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.'); + } + if ($data['action'] === 'withdraw') { + $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])]; + } $payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'], 'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp]; + return ['action' => $data['action'], 'projection' => $projection, 'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), 'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false]; } + /** + * @param array $input + * @return array + */ public function confirm(User $actor, int $id, array $input): array { $data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate(); - try { $preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR); } - catch (\Throwable) { abort(422, 'Prévia inválida. Prepare outra prévia.'); } + try { + $preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR); + } catch (\Throwable) { + abort(422, 'Prévia inválida. Prepare outra prévia.'); + } abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403); + return DB::transaction(function () use ($actor, $id, $data, $preview) { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]); $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); @@ -110,6 +144,7 @@ final class OpportunityPromotion if ($replay) { $prior = json_decode($replay->after_data, true); abort_unless($prior['record_id'] === $id && (int) $replay->actor_user_id === $actor->id && hash_equals($prior['request_hash'], $digest), 409, 'Chave usada noutra operação.'); + return ['id' => $id, 'replayed' => true, 'message' => 'Operação já registada; nenhuma alteração repetida.']; } abort_unless(($preview['expires'] ?? 0) >= now()->timestamp, 409, 'A prévia expirou. Prepare outra prévia.'); @@ -124,7 +159,9 @@ final class OpportunityPromotion $action = 'promotional_summary_withdrawn'; } else { $p = $preview['projection']; - if ($p['sector_id'] !== null) abort_unless(\App\Models\Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.'); + if ($p['sector_id'] !== null) { + abort_unless(Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.'); + } $paths = []; foreach ($preview['input']['territory_ids'] ?? [] as $territory) { $path = app(InvestmentGeography::class)->path($territory, true); @@ -141,15 +178,21 @@ final class OpportunityPromotion $context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true]; $action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published'; - foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) $record->{$field} = $p[$field]; - foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) $record->{$field} = null; + foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) { + $record->{$field} = $p[$field]; + } + foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) { + $record->{$field} = null; + } $main = $paths[0] ?? []; $record->primary_territory_id = $main ? end($main)->id : null; $record->province = collect($main)->firstWhere('location_type', 'province')?->name; $record->district = collect($main)->firstWhere('location_type', 'district')?->name; $record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified'); DB::table('opportunity_locations')->where('opportunity_id', $id)->delete(); - foreach ($preview['input']['territory_ids'] ?? [] as $territory) DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]); + foreach ($preview['input']['territory_ids'] ?? [] as $territory) { + DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]); + } $record->status = 'published'; $record->is_featured = false; $record->last_validated_at = null; @@ -164,6 +207,7 @@ final class OpportunityPromotion 'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest, 'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false, 'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]); + return ['id' => $id, 'replayed' => false, 'message' => $preview['action'] === 'withdraw' ? 'Resumo retirado; cadastro e histórico preservados.' : 'Resumo divulgado como Em preparação. Não autoriza execução do investimento.']; }); } diff --git a/app/Services/OpportunityPublicText.php b/app/Services/OpportunityPublicText.php index 5e296a3a..a126c176 100644 --- a/app/Services/OpportunityPublicText.php +++ b/app/Services/OpportunityPublicText.php @@ -10,7 +10,9 @@ final class OpportunityPublicText public static function description(Opportunity $record, string $text): string { $source = data_get($record->workflow_context, 'corridor_agribusiness'); - if (! is_array($source) || ($source['source_sha256'] ?? null) !== CorridorAgribusinessOpportunities::SOURCE_HASH) return $text; + if (! is_array($source) || ($source['source_sha256'] ?? null) !== CorridorAgribusinessOpportunities::SOURCE_HASH) { + return $text; + } $hash = CorridorAgribusinessOpportunities::SOURCE_HASH; $sourceLine = 'Fonte: '.$source['source'].'; SHA-256 '.$hash; @@ -20,13 +22,20 @@ final class OpportunityPublicText $paragraph = trim($paragraph); if (in_array($paragraph, [$sourceLine, $sourceLine.'.', 'Edição: Não indicada.', 'Data-base: Não indicada.', 'Limitações e lacunas: '.CorridorAgribusinessOpportunities::LIMITATIONS, - OpportunityPromotion::NOTICE], true)) continue; - if ($paragraph === 'Enquadramento documental: '.$source['corridor'].'.') $paragraph = $source['corridor'].'.'; + OpportunityPromotion::NOTICE], true)) { + continue; + } + if ($paragraph === 'Enquadramento documental: '.$source['corridor'].'.') { + $paragraph = $source['corridor'].'.'; + } if ($paragraph === CorridorAgribusinessOpportunities::LIMITATIONS) { $paragraph = 'A localização exacta, a disponibilidade de terreno e as condições do investimento serão definidas durante a preparação do projecto.'; } - if ($paragraph !== '') $result[] = $paragraph; + if ($paragraph !== '') { + $result[] = $paragraph; + } } + return implode("\n\n", $result); } } diff --git a/app/Services/OpportunityTerritorialPresentation.php b/app/Services/OpportunityTerritorialPresentation.php index 34d348e9..28cac16e 100644 --- a/app/Services/OpportunityTerritorialPresentation.php +++ b/app/Services/OpportunityTerritorialPresentation.php @@ -7,22 +7,33 @@ use App\Models\Opportunity; /** Read-only presentation of existing references. Does not reconcile, confirm or save data. */ class OpportunityTerritorialPresentation { + /** + * @param array $locations + * @return array + */ public function describe(Opportunity $record, array $locations): array { $path = array_values(array_filter(app(InvestmentGeography::class)->path($record->primary_territory_id, true), fn ($node) => in_array($node->location_type, ['province', 'district', 'admin_post', 'locality'], true))); $mainIds = array_column($path, 'id', 'location_type'); $differences = []; foreach ($locations as $location) { - if ($location->deleted_at || empty($location->payload['is_primary']) || !$path) continue; + if ($location->deleted_at || empty($location->payload['is_primary']) || ! $path) { + continue; + } $p = $location->payload; $different = false; - foreach ($mainIds as $level => $id) if (!empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) $different = true; - $foreign = !empty($p['country_code']) && $p['country_code'] !== 'MZ'; + foreach ($mainIds as $level => $id) { + if (! empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) { + $different = true; + } + } + $foreign = ! empty($p['country_code']) && $p['country_code'] !== 'MZ'; if ($foreign || $different) { - $differences[] = ['resource_id' => $location->id, 'title' => $p['title'] ?? 'Localização sem título', + $differences[] = ['resource_id' => data_get($location, 'id'), 'title' => $p['title'] ?? 'Localização sem título', 'message' => 'A referência principal da ficha e esta localização documentada apontam para territórios diferentes. Confirme se a distinção é intencional; nenhum valor foi substituído.']; } } + return ['path' => $path, 'differences' => $differences]; } } diff --git a/app/Services/PortalInternalDesignation.php b/app/Services/PortalInternalDesignation.php index 5b593c9d..8291e1fa 100644 --- a/app/Services/PortalInternalDesignation.php +++ b/app/Services/PortalInternalDesignation.php @@ -11,95 +11,131 @@ use Illuminate\Validation\Rule; /** Owner-directed staff assignments use existing identities, organizations and grants. */ class PortalInternalDesignation { + /** + * @return array + */ public function organizations(): array { - return DB::table('organizations')->whereIn(DB::raw('id::text'), DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered')->selectRaw("after_data->>'organization_id'"))->whereNull('deleted_at') - ->get(['id','name','external_id','version_no'])->map(fn($r)=>$this->organization($r->id))->filter()->values()->all(); + return DB::table('organizations')->whereIn(DB::raw('id::text'), DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered')->selectRaw("after_data->>'organization_id'"))->whereNull('deleted_at') + ->get(['id', 'name', 'external_id', 'version_no'])->map(fn ($r) => $this->organization($r->id))->filter()->values()->all(); } + /** + * @return array|null + */ public function organization(?string $id): ?array { - if ($id===null) return null; - $r=DB::table('organizations')->where('id',$id)->whereNull('deleted_at')->first(); - if (!$r) return null; - $event=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered') - ->where('after_data->organization_id',$id)->orderByDesc('occurred_at')->first(); - if (!$event) return null; - $data=json_decode($event->after_data,true,512,JSON_THROW_ON_ERROR); - if (($data['organization']['name'] ?? null)!==$r->name || ($data['organization']['nuit'] ?? null)!==$r->external_id) return null; - return ['id'=>$r->id,'version'=>$r->version_no,...$data['organization'],'source_reference'=>$data['source_reference']]; + if ($id === null) { + return null; + } + $r = DB::table('organizations')->where('id', $id)->whereNull('deleted_at')->first(); + if (! $r) { + return null; + } + $event = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered') + ->where('after_data->organization_id', $id)->orderByDesc('occurred_at')->first(); + if (! $event) { + return null; + } + $data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); + if (($data['organization']['name'] ?? null) !== $r->name || ($data['organization']['nuit'] ?? null) !== $r->external_id) { + return null; + } + + return ['id' => $r->id, 'version' => $r->version_no, ...$data['organization'], 'source_reference' => $data['source_reference']]; } - public function registerOrganization(User $actor,array $input): array + /** + * @param array $input + * @return array + */ + public function registerOrganization(User $actor, array $input): array { app(PortalProfileAdministration::class)->administrative($actor); - $v=Validator::make($input,['operation_key'=>'required|uuid','name'=>'required|string|max:200','nuit'=>'required|string|regex:/^[0-9]{9}$/', - 'contact'=>'required|string|regex:/^\+258[0-9]{9}$/','director'=>'required|string|max:200', - 'function'=>'required|in:portal_owner_operator','source_reference'=>'required|string|min:20|max:1000'])->validate(); - return DB::transaction(function()use($actor,$v){ + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'name' => 'required|string|max:200', 'nuit' => 'required|string|regex:/^[0-9]{9}$/', + 'contact' => 'required|string|regex:/^\+258[0-9]{9}$/', 'director' => 'required|string|max:200', + 'function' => 'required|in:portal_owner_operator', 'source_reference' => 'required|string|min:20|max:1000'])->validate(); + + return DB::transaction(function () use ($actor, $v) { DB::select('SELECT pg_advisory_xact_lock(101026,40001)'); - $payload=array_intersect_key($v,array_flip(['name','nuit','contact','director','function'])); - $existingEvent=DB::table('audit_events')->where('source_system','portal-profiles-v1')->whereIn('action',['operator_organization_registered','operator_organization_registration_recovered'])->where('after_data->operation_key',$v['operation_key'])->first(); - if($existingEvent){ - $e=json_decode($existingEvent->after_data,true); - abort_unless($e['organization']==$payload && $e['source_reference']===$v['source_reference'],409,'Esta chave corresponde a outro cadastro.'); - return ['id'=>$e['organization_id'],'organization'=>$this->organization($e['organization_id']),'message'=>'Cadastro recuperado; não repetido.']; + $payload = array_intersect_key($v, array_flip(['name', 'nuit', 'contact', 'director', 'function'])); + $existingEvent = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['operator_organization_registered', 'operator_organization_registration_recovered'])->where('after_data->operation_key', $v['operation_key'])->first(); + if ($existingEvent) { + $e = json_decode($existingEvent->after_data, true); + abort_unless($e['organization'] == $payload && $e['source_reference'] === $v['source_reference'], 409, 'Esta chave corresponde a outro cadastro.'); + + return ['id' => $e['organization_id'], 'organization' => $this->organization($e['organization_id']), 'message' => 'Cadastro recuperado; não repetido.']; } - $names=array_unique([$v['name'],preg_replace('/\s*,\s*/',' ',$v['name'])]); - $matches=DB::table('organizations')->where(fn($q)=>$q->where('external_id',$v['nuit'])->orWhereIn('name',$names)->orWhere('name',str_replace(' Lda',', Lda',$v['name'])))->lockForUpdate()->get(); - abort_if($matches->count()>1,409,'Correspondência ambígua no cadastro mestre; não criar duplicado.'); - $r=$matches->first(); - if($r){ - abort_if($r->deleted_at!==null || ($r->external_id!==null && $r->external_id!==$v['nuit']),409,'Identidade ou estado da organização exige reconciliação.'); - $known=$this->organization($r->id); - if($known){ - abort_unless(array_intersect_key($known,$payload)==$payload,409,'Dados do cadastro já existente diferem; preservar a versão anterior.'); - app(PortalProfileAccess::class)->audit($actor,'operator_organization_registration_recovered',['operation_key'=>$v['operation_key'],'organization_id'=>$r->id,'organization'=>$payload,'source_reference'=>$v['source_reference']]); - return ['id'=>$r->id,'organization'=>$known,'message'=>'Organização existente recuperada.']; + $names = array_unique([$v['name'], preg_replace('/\s*,\s*/', ' ', $v['name'])]); + $matches = DB::table('organizations')->where(fn ($q) => $q->where('external_id', $v['nuit'])->orWhereIn('name', $names)->orWhere('name', str_replace(' Lda', ', Lda', $v['name'])))->lockForUpdate()->get(); + abort_if($matches->count() > 1, 409, 'Correspondência ambígua no cadastro mestre; não criar duplicado.'); + $r = $matches->first(); + if ($r) { + abort_if($r->deleted_at !== null || ($r->external_id !== null && $r->external_id !== $v['nuit']), 409, 'Identidade ou estado da organização exige reconciliação.'); + $known = $this->organization($r->id); + if ($known) { + abort_unless(array_intersect_key($known, $payload) == $payload, 409, 'Dados do cadastro já existente diferem; preservar a versão anterior.'); + app(PortalProfileAccess::class)->audit($actor, 'operator_organization_registration_recovered', ['operation_key' => $v['operation_key'], 'organization_id' => $r->id, 'organization' => $payload, 'source_reference' => $v['source_reference']]); + + return ['id' => $r->id, 'organization' => $known, 'message' => 'Organização existente recuperada.']; } - $id=$r->id; - $before=['name'=>$r->name,'external_id'=>$r->external_id,'source_system'=>$r->source_system,'version_no'=>$r->version_no]; - abort_unless(in_array($r->name,$names,true) || $r->name===str_replace(' Lda',', Lda',$v['name']),409,'Denominação divergente exige reconciliação expressa.'); - DB::table('organizations')->where('id',$id)->update(['name'=>$v['name'],'external_id'=>$v['nuit'],'updated_at'=>now(),'version_no'=>$r->version_no+1]); + $id = $r->id; + $before = ['name' => $r->name, 'external_id' => $r->external_id, 'source_system' => $r->source_system, 'version_no' => $r->version_no]; + abort_unless(in_array($r->name, $names, true) || $r->name === str_replace(' Lda', ', Lda', $v['name']), 409, 'Denominação divergente exige reconciliação expressa.'); + DB::table('organizations')->where('id', $id)->update(['name' => $v['name'], 'external_id' => $v['nuit'], 'updated_at' => now(), 'version_no' => $r->version_no + 1]); // Reconcile only the explicit comma alias; preserve previous values in audit. - }else{ - $id=(string)Str::uuid(); - DB::table('organizations')->insert(['id'=>$id,'name'=>$v['name'],'external_id'=>$v['nuit'],'source_system'=>'portal-operator-master','organization_type'=>'private']); + } else { + $id = (string) Str::uuid(); + DB::table('organizations')->insert(['id' => $id, 'name' => $v['name'], 'external_id' => $v['nuit'], 'source_system' => 'portal-operator-master', 'organization_type' => 'private']); } - app(PortalProfileAccess::class)->audit($actor,'operator_organization_registered',['operation_key'=>$v['operation_key'],'organization_id'=>$id, - 'organization'=>$payload,'previous_master_values'=>$before??null,'source_reference'=>$v['source_reference'],'metadata_storage'=>'existing_audit_snapshot','no_corporate_membership_created'=>true]); - return ['id'=>$id,'organization'=>$this->organization($id),'message'=>'Organização operadora registada no cadastro mestre. Nenhuma representação ou papel foi atribuído.']; + app(PortalProfileAccess::class)->audit($actor, 'operator_organization_registered', ['operation_key' => $v['operation_key'], 'organization_id' => $id, + 'organization' => $payload, 'previous_master_values' => $before ?? null, 'source_reference' => $v['source_reference'], 'metadata_storage' => 'existing_audit_snapshot', 'no_corporate_membership_created' => true]); + + return ['id' => $id, 'organization' => $this->organization($id), 'message' => 'Organização operadora registada no cadastro mestre. Nenhuma representação ou papel foi atribuído.']; }); } - public function grant(User $actor,array $input): string + /** + * @param array $input + */ + public function grant(User $actor, array $input): string { app(PortalProfileAdministration::class)->administrative($actor); - $v=Validator::make($input,['operation_key'=>'required|uuid','user_id'=>'required|integer|exists:users,id','role'=>['required',Rule::in(['promoter','validator','publisher'])], - 'operator_organization_id'=>'required|uuid|exists:organizations,id','organization_id'=>'nullable|uuid|exists:organizations,id', - 'opportunity_types'=>'required|array|min:1','opportunity_types.*'=>['required',Rule::in(OpportunityCanonicalContract::BASE_CATEGORIES)], - 'confirmed_internal_designation'=>'required|accepted','reason'=>'required|string|min:20|max:1000', - 'responsibilities'=>'sometimes|array|max:0','expires_at'=>'nullable|date|after:now'])->validate(); - abort_if((int)$v['user_id']===$actor->id,403,'Não pode aprovar a sua atribuição.'); - $subject=User::findOrFail($v['user_id']); - abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret!==null && $subject->two_factor_confirmed_at!==null,422,'Confirmar contacto e o próprio MFA antes da atribuição interna.'); - $organization=$this->organization($v['operator_organization_id']); - abort_unless($organization!==null,422,'Selecione a organização operadora cadastrada; não exige comprovativos de Promotor externo.'); - if($v['role']==='promoter')abort_unless(($v['organization_id']??null)===$v['operator_organization_id'],422,'O vínculo interno é à organização operadora, não uma representação de gestão empresarial.'); - return DB::transaction(function()use($actor,$v){ + $v = Validator::make($input, ['operation_key' => 'required|uuid', 'user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher'])], + 'operator_organization_id' => 'required|uuid|exists:organizations,id', 'organization_id' => 'nullable|uuid|exists:organizations,id', + 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityCanonicalContract::BASE_CATEGORIES)], + 'confirmed_internal_designation' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000', + 'responsibilities' => 'sometimes|array|max:0', 'expires_at' => 'nullable|date|after:now'])->validate(); + abort_if((int) $v['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.'); + $subject = User::whereKey($v['user_id'])->firstOrFail(); + abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null, 422, 'Confirmar contacto e o próprio MFA antes da atribuição interna.'); + $organization = $this->organization($v['operator_organization_id']); + abort_unless($organization !== null, 422, 'Selecione a organização operadora cadastrada; não exige comprovativos de Promotor externo.'); + if ($v['role'] === 'promoter') { + abort_unless(($v['organization_id'] ?? null) === $v['operator_organization_id'], 422, 'O vínculo interno é à organização operadora, não uma representação de gestão empresarial.'); + } + + return DB::transaction(function () use ($actor, $v) { DB::select('SELECT pg_advisory_xact_lock(101026,40002)'); - $subject=User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail(); - abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret!==null && $subject->two_factor_confirmed_at!==null && $this->organization($v['operator_organization_id'])!==null,422,'Conta ou organização já não reúne as condições da atribuição.'); - $e=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','role_granted')->where('after_data->operation_key',$v['operation_key'])->first(); - $payload=array_intersect_key($v,array_flip(['user_id','role','operator_organization_id','organization_id','opportunity_types','reason','expires_at'])); - if($e){$d=json_decode($e->after_data,true);abort_unless($d['designation']==$payload,409,'Chave usada numa atribuição diferente.');abort_unless(DB::table('portal_role_assignments')->where('id',$d['assignment_id'])->whereNull('revoked_at')->exists(),409,'A atribuição foi revogada.');return $d['assignment_id'];} - $duplicate=DB::table('portal_role_assignments')->where('user_id',$v['user_id'])->where('role',$v['role'])->where('source','internal-portal-designation')->whereNull('revoked_at')->where('starts_at','<=',now())->where(fn($q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now()))->exists(); - abort_if($duplicate,409,'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.'); - $id=(string)Str::uuid(); - app(PortalProfileAdministration::class)->insertGrant($id,$actor,[...$v,'source'=>'internal-portal-designation']); - app(PortalProfileAccess::class)->audit($actor,'role_granted',['assignment_id'=>$id,'subject_user_id'=>$v['user_id'],'role'=>$v['role'], - 'operation_key'=>$v['operation_key'],'designation'=>$payload,'operator_organization_id'=>$v['operator_organization_id'], - 'reason'=>$v['reason'],'authority'=>'portal-owner-internal-designation','external_promoter_documents_required'=>false,'no_corporate_membership_created'=>true]); + $subject = User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail(); + abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null && $this->organization($v['operator_organization_id']) !== null, 422, 'Conta ou organização já não reúne as condições da atribuição.'); + $e = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'role_granted')->where('after_data->operation_key', $v['operation_key'])->first(); + $payload = array_intersect_key($v, array_flip(['user_id', 'role', 'operator_organization_id', 'organization_id', 'opportunity_types', 'reason', 'expires_at'])); + if ($e) { + $d = json_decode($e->after_data, true); + abort_unless($d['designation'] == $payload, 409, 'Chave usada numa atribuição diferente.'); + abort_unless(DB::table('portal_role_assignments')->where('id', $d['assignment_id'])->whereNull('revoked_at')->exists(), 409, 'A atribuição foi revogada.'); + + return $d['assignment_id']; + } + $duplicate = DB::table('portal_role_assignments')->where('user_id', $v['user_id'])->where('role', $v['role'])->where('source', 'internal-portal-designation')->whereNull('revoked_at')->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(); + abort_if($duplicate, 409, 'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.'); + $id = (string) Str::uuid(); + app(PortalProfileAdministration::class)->insertGrant($id, $actor, [...$v, 'source' => 'internal-portal-designation']); + app(PortalProfileAccess::class)->audit($actor, 'role_granted', ['assignment_id' => $id, 'subject_user_id' => $v['user_id'], 'role' => $v['role'], + 'operation_key' => $v['operation_key'], 'designation' => $payload, 'operator_organization_id' => $v['operator_organization_id'], + 'reason' => $v['reason'], 'authority' => 'portal-owner-internal-designation', 'external_promoter_documents_required' => false, 'no_corporate_membership_created' => true]); + return $id; }); } diff --git a/app/Services/PortalProfileInstallation.php b/app/Services/PortalProfileInstallation.php index 493faf3b..38bde626 100644 --- a/app/Services/PortalProfileInstallation.php +++ b/app/Services/PortalProfileInstallation.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; /** Additive installation and separate, explicit bootstrap. No identities are inferred. */ class PortalProfileInstallation @@ -33,6 +34,7 @@ class PortalProfileInstallation return false; } $target = config('portal_profiles.bootstrap_designated_database', ''); + return $target !== '' && DB::selectOne('SELECT current_database() AS database')->database === $target; } @@ -134,40 +136,47 @@ class PortalProfileInstallation /** @param array $input * @return array */ - public function bootstrap(User $actor, array $input): array { $this->authorize($actor, true, true); $data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'first_user_id' => 'required|integer|exists:users,id', 'second_user_id' => 'required|integer|different:first_user_id|exists:users,id', 'reason' => 'required|string|min:20|max:1000'])->validate(); abort_unless((int) $data['first_user_id'] === $actor->id, 403, 'O primeiro titular deve apresentar o seu próprio pedido; não é permitido inicializar terceiros.'); + return $this->receipt($actor, 'bootstrap-request', $data, function () use ($actor, $data) { $p = $this->preview($actor); abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado.'); abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Schema consistente instalado e dupla não inicializada necessários.'); - $second = User::findOrFail($data['second_user_id']); + $second = User::whereKey($data['second_user_id'])->firstOrFail(); if ($this->designatedBootstrap($actor)) { abort_unless($this->designatedBootstrap($second), 403, 'Use os dois titulares expressamente designados para este destino.'); } - foreach ([$actor, $second] as $user) app(PortalSuperusers::class)->validateBootstrapHolder($user); + foreach ([$actor, $second] as $user) { + app(PortalSuperusers::class)->validateBootstrapHolder($user); + } abort_if(DB::table('portal_security_approvals')->where('kind', 'bootstrap')->whereNull('consumed_at')->where('expires_at', '>', now())->exists(), 409, 'Já existe pedido pendente. Recupere-o; não duplique a cerimónia.'); - $id = (string) \Illuminate\Support\Str::uuid(); + $id = (string) Str::uuid(); DB::table('portal_security_approvals')->insert(['id' => $id, 'kind' => 'bootstrap', 'requested_by' => $actor->id, 'subject_user_id' => $second->id, 'scope' => json_encode(['first_user_id' => $actor->id, 'second_user_id' => $second->id, 'code_hash' => $this->fingerprint(), 'target' => $p['target'], 'preview_hash' => $p['hash']], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'expires_at' => now()->addMinutes(15), 'created_at' => now(), 'updated_at' => now()]); + return ['initialized' => false, 'pending' => true, 'approval_id' => $id, 'confirmation_url' => '/management/profile-install/bootstrap/'.$id, 'holders' => [$actor->id, $second->id], 'expires_in_seconds' => 900, 'flags_changed' => false, 'privileges_granted' => false]; }); } - /** Purpose-specific view; designation grants no general management access. */ + /** Purpose-specific view; designation grants no general management access. + * @return array + */ public function bootstrapPreview(User $actor, string $id): array { abort_unless($actor->hasVerifiedEmail(), 403); $row = DB::table('portal_security_approvals')->where('id', $id)->where('kind', 'bootstrap')->first(); - abort_unless($row, 404); - abort_unless(in_array($actor->id, [(int) $row->requested_by, (int) $row->subject_user_id], true), 403, 'Apenas os dois titulares designados consultam este pedido.'); + abort_unless($row !== null, 404); + if (! (in_array($actor->id, [(int) $row->requested_by, (int) $row->subject_user_id], true))) { + abort(403, 'Apenas os dois titulares designados consultam este pedido.'); + } app(PortalSuperusers::class)->validateBootstrapHolder($actor); $scope = json_decode($row->scope, true, 512, JSON_THROW_ON_ERROR); $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); @@ -178,9 +187,14 @@ class PortalProfileInstallation 'can_confirm' => $actor->id === (int) $row->subject_user_id && $row->consumed_at === null && strtotime($row->expires_at) > time() && $current, 'mfa_confirmed_in_session' => $this->access->mfa($actor)]; $view['hash'] = hash('sha256', json_encode([$id, $row->requested_by, $row->subject_user_id, $row->scope, $row->reason, $row->expires_at], JSON_THROW_ON_ERROR)); + return $view; } + /** + * @param array $input + * @return array + */ public function confirmBootstrap(User $actor, string $id, array $input): array { $view = $this->bootstrapPreview($actor, $id); @@ -188,12 +202,13 @@ class PortalProfileInstallation abort_unless($this->access->mfa($actor), 403, 'Conclua MFA nesta sessão.'); $data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate(); $data['approval_id'] = $id; + return $this->receipt($actor, 'bootstrap-confirm', $data, function () use ($actor, $id, $data) { $row = DB::table('portal_security_approvals')->where('id', $id)->lockForUpdate()->first(); $view = $this->bootstrapPreview($actor, $id); abort_unless(hash_equals($view['hash'], $data['hash']), 409, 'Pedido alterado; consulte novamente.'); abort_unless($view['can_confirm'], 409, 'Pedido expirado, já consumido ou revisão/destino alterados.'); - $first = User::findOrFail($row->requested_by); + $first = User::whereKey($row->requested_by)->firstOrFail(); $this->authorize($first, false, true); $p = $this->preview($first); abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Dependências ou dupla alteradas.'); @@ -205,6 +220,7 @@ class PortalProfileInstallation } finally { config(['portal_profiles.bootstrap_authorized' => false]); } + return ['initialized' => true, 'approval_id' => $id, 'holders' => [$first->id, $actor->id], 'version' => 1, 'flags_changed' => false]; }); } diff --git a/app/Services/PortalSuperusers.php b/app/Services/PortalSuperusers.php index 4f616721..4231c179 100644 --- a/app/Services/PortalSuperusers.php +++ b/app/Services/PortalSuperusers.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Opportunity; use App\Models\User; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\DB; @@ -26,7 +27,6 @@ class PortalSuperusers return $plain; } - public function validateBootstrapHolder(User $user): void { abort_unless($user->hasVerifiedEmail() && $user->two_factor_secret && $user->two_factor_confirmed_at, 422, 'Conta confirmada com MFA necessário.'); @@ -44,7 +44,9 @@ class PortalSuperusers { abort_unless(config('portal_profiles.bootstrap_authorized') === true, 403); abort_unless($first->id !== $second->id && $confirmer->id === $second->id, 403, 'Confirmação pelo segundo titular, distinta do pedido.'); - foreach ([$first, $second] as $user) $this->validateBootstrapHolder($user); + foreach ([$first, $second] as $user) { + $this->validateBootstrapHolder($user); + } abort_unless($this->access->mfa($confirmer), 403); DB::transaction(function () use ($first, $second, $confirmer, $approvalId) { DB::select('SELECT pg_advisory_xact_lock(101026, 40001)'); @@ -123,7 +125,7 @@ class PortalSuperusers $this->administration->administrative($actor); abort_unless(in_array($action, ['view', 'create', 'edit', 'validate', 'publish', 'archive'], true) && mb_strlen($reason) >= 20 && mb_strlen($reason) <= 1000, 422); if ($action === 'view') { - $record = $recordId === null ? null : \App\Models\Opportunity::find($recordId); + $record = $recordId === null ? null : Opportunity::find($recordId); abort_unless($record && $this->access->hasOperationalReadScope($actor, $record) && ! $this->access->has($actor, 'auditor'), 403, 'A consulta exige perfil operacional com âmbito válido; a elevação não o substitui.'); } $id = (string) Str::uuid(); diff --git a/app/Services/PrivateInvestmentPreparation.php b/app/Services/PrivateInvestmentPreparation.php index fedb6879..569919f8 100644 --- a/app/Services/PrivateInvestmentPreparation.php +++ b/app/Services/PrivateInvestmentPreparation.php @@ -22,6 +22,9 @@ final class PrivateInvestmentPreparation return (bool) config('investor-path.private_preparation', false); } + /** + * @return array + */ public function questions(): array { $tri = ['unknown' => 'Por determinar', 'yes' => 'Sim', 'no' => 'Não']; @@ -44,6 +47,9 @@ final class PrivateInvestmentPreparation ]; } + /** + * @param array $f + */ private function number(array $f, string $key): ?BigDecimal { $value = $f[$key] ?? ''; @@ -51,19 +57,47 @@ final class PrivateInvestmentPreparation return is_scalar($value) && preg_match('/^\d+(?:\.\d+)?$/D', (string) $value) ? BigDecimal::of((string) $value) : null; } + /** @param array $fields + * @param list $missing + */ + private function answer(array $fields, string $key, array &$missing): ?bool + { + $value = $fields[$key] ?? null; + if ($value === 'yes') { + return true; + } + if ($value === 'no') { + return false; + } + $missing[] = $key; + + return null; + } + + /** @param array $triggers + * @return list + */ + private function triggerReasons(array $triggers): array + { + $reasons = []; + foreach (['amount' => 'Valor superior a 32 mil milhões MZN — R13(2), R21(2)(a)', 'project_concession' => 'PPP/concessão empresarial — Lei 8/2023, art. 22(1)(b)(ii), R21(2)(b)', 'significant_implications' => 'Implicações significativas declaradas — Lei 8/2023, art. 22(1)(b)(i), R21(1)', 'land_extension' => 'Extensão de terra igual/superior a 10 mil hectares — Lei 8/2023, art. 22(1)(b)(iii), R21(2)(c)', 'forest_concession' => 'Concessão florestal superior a 100 mil hectares — Lei 8/2023, art. 22(1)(b)(iv), R21(2)(d)'] as $condition => $description) { + if (($triggers[$condition] ?? null) === true) { + $reasons[] = $description.'.'; + } + } + + return $reasons; + } + + /** + * @param array $context + * @param array $documents + * @return array + */ public function assess(array $context, array $documents = []): array { $f = $context['fields'] ?? []; $missing = []; - $answer = function (string $key) use ($f, &$missing): ?bool { - if (! in_array($f[$key] ?? '', ['yes', 'no'], true)) { - $missing[] = $key; - - return null; - } - - return $f[$key] === 'yes'; - }; $enabled = ($f['investment_preparation'] ?? '') === 'yes'; $date = $f['expected_start_date'] ?? ''; $period = preg_match('/^2026-\d{2}-\d{2}$/D', $date) === 1; @@ -102,9 +136,9 @@ final class PrivateInvestmentPreparation $reasons[] = 'A condição monetária não é determinada noutra moeda: não se fez conversão.'; } foreach (['project_concession', 'significant_implications'] as $key) { - $triggers[$key] = $answer($key); + $triggers[$key] = $this->answer($f, $key, $missing); } - $land = $answer('land_extension'); + $land = $this->answer($f, 'land_extension', $missing); if ($land === true) { $area = $this->number($f, 'area_required'); $unit = $f['area_unit'] ?? ''; @@ -117,7 +151,7 @@ final class PrivateInvestmentPreparation } else { $triggers['land_extension'] = $land; } - $forest = $answer('forest_concession'); + $forest = $this->answer($f, 'forest_concession', $missing); if ($forest === true) { $area = $this->number($f, 'forest_concession_hectares'); if (! $area) { @@ -126,11 +160,7 @@ final class PrivateInvestmentPreparation } else { $triggers['forest_concession'] = $forest; } - foreach (['amount' => 'Valor superior a 32 mil milhões MZN — R13(2), R21(2)(a)', 'project_concession' => 'PPP/concessão empresarial — Lei 8/2023, art. 22(1)(b)(ii), R21(2)(b)', 'significant_implications' => 'Implicações significativas declaradas — Lei 8/2023, art. 22(1)(b)(i), R21(1)', 'land_extension' => 'Extensão de terra igual/superior a 10 mil hectares — Lei 8/2023, art. 22(1)(b)(iii), R21(2)(c)', 'forest_concession' => 'Concessão florestal superior a 100 mil hectares — Lei 8/2023, art. 22(1)(b)(iv), R21(2)(d)'] as $condition => $description) { - if (($triggers[$condition] ?? null) === true) { - $reasons[] = $description.'.'; - } - } + $reasons = [...$reasons, ...$this->triggerReasons($triggers)]; $mode = in_array(true, $triggers, true) ? 'authorization' : (in_array(null, $triggers, true) ? 'indeterminate' : 'registration'); $reasons[] = $mode === 'authorization' ? 'Foi identificada uma condição de autorização; as restantes condições desconhecidas não foram presumidas negativas.' : ($mode === 'registration' ? 'As condições de autorização deste lote foram declaradas negativas e o montante não ultrapassa o limite.' : 'Falta resolver pelo menos uma condição de autorização.'); } diff --git a/app/Services/ProactiveInvestment.php b/app/Services/ProactiveInvestment.php index cce451cd..52cb6757 100644 --- a/app/Services/ProactiveInvestment.php +++ b/app/Services/ProactiveInvestment.php @@ -3,8 +3,10 @@ namespace App\Services; use App\Models\Opportunity; -use Illuminate\Support\Facades\DB; +use App\Models\User; +use App\Support\StructuredData; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -13,33 +15,39 @@ use Illuminate\Validation\ValidationException; final class ProactiveInvestment { public const VERSION = 1; + public const NOTICE = 'A ausência de registo não comprova ausência de oferta. Proximidade não comprova contrato, participação, energia acessível, terra disponível ou benefício fiscal. Necessidade confirmada não comprova viabilidade comercial; hipótese de fornecimento não é encomenda.'; + public const GROUPS = ['exists' => 'O que existe', 'needs' => 'Necessidades identificadas', 'ideas' => 'Ideias de investimento']; - public function draft(array $input, ?Opportunity $record = null, ?\App\Models\User $actor = null): array + /** + * @param array $input + * @return array + */ + public function draft(array $input, ?Opportunity $record = null, ?User $actor = null): array { $d = Validator::make(['proactive' => $input], [ 'proactive' => 'required|array:version,kind,evidence,need,idea,relations,supply_comparisons', 'proactive.version' => 'required|integer|in:1', 'proactive.kind' => 'required|in:exists,needs,ideas', 'proactive.evidence' => 'present|array|max:60', 'proactive.evidence.*' => 'array:id,category,purpose,statement,interpretation,source,locator,period,scope,coverage,verified_on,access,document_id,observation_id', - 'proactive.evidence.*.observation_id'=>'sometimes|nullable|string|size:64', - 'proactive.supply_comparisons'=>'sometimes|array|max:40', + 'proactive.evidence.*.observation_id' => 'sometimes|nullable|string|size:64', + 'proactive.supply_comparisons' => 'sometimes|array|max:40', 'proactive.evidence.*.id' => 'required|uuid|distinct', 'proactive.evidence.*.category' => 'required|in:activity,resource,infrastructure,supply,demand', 'proactive.evidence.*.purpose' => 'required|in:local,methodology', 'proactive.evidence.*.access' => 'required|in:internal,public', 'proactive.evidence.*.document_id' => 'nullable|uuid', 'proactive.evidence.*.verified_on' => 'nullable|date_format:Y-m-d|before_or_equal:today', - ...collect(['statement','interpretation','source','locator','period','scope','coverage'])->mapWithKeys(fn ($k) => ['proactive.evidence.*.'.$k => 'present|nullable|string|max:4000'])->all(), + ...collect(['statement', 'interpretation', 'source', 'locator', 'period', 'scope', 'coverage'])->mapWithKeys(fn ($k) => ['proactive.evidence.*.'.$k => 'present|nullable|string|max:4000'])->all(), 'proactive.need' => 'required|array:state,problem,observed_supply,observed_demand,type,clients,evidence_ids,limitations,missing,confirmation', 'proactive.need.state' => 'required|in:hypothesis,confirmed', 'proactive.need.type' => 'nullable|in:quantity,quality,logistics,processing,coordination,support', - ...collect(['problem','observed_supply','observed_demand','clients','limitations','missing','confirmation'])->mapWithKeys(fn ($k) => ['proactive.need.'.$k => 'present|nullable|string|max:6000'])->all(), + ...collect(['problem', 'observed_supply', 'observed_demand', 'clients', 'limitations', 'missing', 'confirmation'])->mapWithKeys(fn ($k) => ['proactive.need.'.$k => 'present|nullable|string|max:6000'])->all(), 'proactive.need.evidence_ids' => 'present|array|max:60', 'proactive.need.evidence_ids.*' => 'uuid|distinct', 'proactive.idea' => 'required|array:solution,activity,chain_id,stage,coverage,mode,resources,assumptions,conditions,pending,next_action', - 'proactive.idea.chain_id' => ['nullable','uuid',Rule::exists('value_chains','id')->whereNull('deleted_at')], + 'proactive.idea.chain_id' => ['nullable', 'uuid', Rule::exists('value_chains', 'id')->whereNull('deleted_at')], 'proactive.idea.mode' => 'nullable|in:create,expand,associate', - ...collect(['solution','activity','stage','coverage','resources','assumptions','conditions','pending','next_action'])->mapWithKeys(fn ($k) => ['proactive.idea.'.$k => 'present|nullable|string|max:6000'])->all(), + ...collect(['solution', 'activity', 'stage', 'coverage', 'resources', 'assumptions', 'conditions', 'pending', 'next_action'])->mapWithKeys(fn ($k) => ['proactive.idea.'.$k => 'present|nullable|string|max:6000'])->all(), 'proactive.relations' => 'present|array|max:40', 'proactive.relations.*' => 'array:id,kind,target_id,relation,reference,evidence_ids', 'proactive.relations.*.id' => 'required|uuid|distinct', @@ -49,144 +57,220 @@ final class ProactiveInvestment 'proactive.relations.*.reference' => 'required|string|max:4000', 'proactive.relations.*.evidence_ids' => 'present|array|max:60', 'proactive.relations.*.evidence_ids.*' => 'uuid|distinct', ])->validate()['proactive']; - $actor??=auth()->user(); - if (!empty($d['supply_comparisons'])) $d['supply_comparisons']=app(ProactiveSupply::class)->comparisons($actor,$d['supply_comparisons'],$record); - foreach($d['evidence'] as &$proof){ - if(!empty($proof['observation_id'])){ - $canonical=app(ProactiveSupply::class)->evidence($actor,$proof['observation_id'],$record); - if($proof['access']!=='internal') $this->fail('evidence','A observação da base interna não pode ser reclassificada como pública.'); - $proof=array_replace($proof,$canonical); + $actor ??= auth()->user(); + if (! empty($d['supply_comparisons'])) { + $d['supply_comparisons'] = app(ProactiveSupply::class)->comparisons($actor, $d['supply_comparisons'], $record); + } + foreach ($d['evidence'] as &$proof) { + if (! empty($proof['observation_id'])) { + $canonical = app(ProactiveSupply::class)->evidence($actor, $proof['observation_id'], $record); + if ($proof['access'] !== 'internal') { + $this->fail('evidence', 'A observação da base interna não pode ser reclassificada como pública.'); + } + $proof = array_replace($proof, $canonical); } } unset($proof); - foreach($d['supply_comparisons']??[] as $comparison){ - if(!in_array($comparison['observation_id'],array_column($d['evidence'],'observation_id'),true))$this->fail('supply_comparisons','A comparação deve conservar a evidência interna da observação seleccionada.'); + foreach ($d['supply_comparisons'] ?? [] as $comparison) { + if (! in_array($comparison['observation_id'], array_column($d['evidence'], 'observation_id'), true)) { + $this->fail('supply_comparisons', 'A comparação deve conservar a evidência interna da observação seleccionada.'); + } } $ids = array_column($d['evidence'], 'id'); foreach ([$d['need'], ...$d['relations']] as $row) { - if (array_diff($row['evidence_ids'], $ids)) $this->fail('evidence', 'Seleccione apenas evidências desta análise.'); + if (array_diff($row['evidence_ids'], $ids)) { + $this->fail('evidence', 'Seleccione apenas evidências desta análise.'); + } } $seen = []; foreach ($d['evidence'] as $e) { $key = mb_strtolower(trim(($e['source'] ?? '').'|'.($e['locator'] ?? '').'|'.($e['statement'] ?? ''))); - if (trim($key, '| ') !== '' && isset($seen[$key])) $this->fail('evidence', 'A mesma evidência já foi registada; edite a entrada existente.'); + if (trim($key, '| ') !== '' && isset($seen[$key])) { + $this->fail('evidence', 'A mesma evidência já foi registada; edite a entrada existente.'); + } $seen[$key] = true; - if (! empty($e['document_id'])) $this->document($record, $e['document_id'], $e['access'] === 'public'); + if (! empty($e['document_id'])) { + $this->document($record, $e['document_id'], $e['access'] === 'public'); + } } $seen = []; foreach ($d['relations'] as $row) { $target = app(OpportunityClassification::class)->targets($row['kind'])->where('id', $row['target_id'])->exists(); - if (! $target) $this->fail('relations', 'Referência existente indisponível; não foi criada outra empresa, zona ou estrutura.'); + if (! $target) { + $this->fail('relations', 'Referência existente indisponível; não foi criada outra empresa, zona ou estrutura.'); + } $key = $row['kind'].'|'.$row['target_id']; - if (isset($seen[$key])) $this->fail('relations', 'A referência já está nesta análise; reúna o fundamento na entrada existente.'); + if (isset($seen[$key])) { + $this->fail('relations', 'A referência já está nesta análise; reúna o fundamento na entrada existente.'); + } $seen[$key] = true; - if ($row['relation'] === 'documented' && ! $row['evidence_ids']) $this->fail('relations', 'Relação documentada exige evidência seleccionada; mantenha hipótese ou proximidade quando não demonstrada.'); + if ($row['relation'] === 'documented' && ! $row['evidence_ids']) { + $this->fail('relations', 'Relação documentada exige evidência seleccionada; mantenha hipótese ou proximidade quando não demonstrada.'); + } } if ($d['kind'] !== 'exists' && $d['need']['state'] === 'confirmed') { - foreach (['problem','observed_supply','observed_demand','type','clients','confirmation'] as $field) { - if (trim($d['need'][$field] ?? '') === '') $this->fail('need.'.$field, 'Confirmação exige observação e fundamento concretos; pode guardar como hipótese.'); + foreach (['problem', 'observed_supply', 'observed_demand', 'type', 'clients', 'confirmation'] as $field) { + if (trim($d['need'][$field] ?? '') === '') { + $this->fail('need.'.$field, 'Confirmação exige observação e fundamento concretos; pode guardar como hipótese.'); + } + } + if (! $d['need']['evidence_ids']) { + $this->fail('need.evidence_ids', 'Uma hipótese não é confirmada sem evidência seleccionada.'); } - if (! $d['need']['evidence_ids']) $this->fail('need.evidence_ids', 'Uma hipótese não é confirmada sem evidência seleccionada.'); foreach ($d['evidence'] as $e) { - if (! in_array($e['id'], $d['need']['evidence_ids'], true)) continue; - if ($e['purpose'] !== 'local') $this->fail('need.evidence_ids', 'Uma referência metodológica internacional não confirma oferta ou procura local. Preserve a hipótese e recolha evidência local.'); - foreach (['statement','source','locator','period','scope','coverage','verified_on'] as $field) { - if (trim($e[$field] ?? '') === '') $this->fail('evidence', 'Evidência da necessidade confirmada: faltam facto, fonte, localizador, período, abrangência, cobertura ou data de verificação.'); + if (! in_array($e['id'], $d['need']['evidence_ids'], true)) { + continue; + } + if ($e['purpose'] !== 'local') { + $this->fail('need.evidence_ids', 'Uma referência metodológica internacional não confirma oferta ou procura local. Preserve a hipótese e recolha evidência local.'); + } + foreach (['statement', 'source', 'locator', 'period', 'scope', 'coverage', 'verified_on'] as $field) { + if (trim($e[$field] ?? '') === '') { + $this->fail('evidence', 'Evidência da necessidade confirmada: faltam facto, fonte, localizador, período, abrangência, cobertura ou data de verificação.'); + } } } } + return $d; } private function document(?Opportunity $record, string $id, bool $public): void { - $row = $record?->exists ? DB::table('opportunity_documents as link')->join('documents as d','d.id','=','link.document_id') - ->where('link.opportunity_id', $record->id)->where('d.id', $id)->whereNull('link.deleted_at')->first(['d.data_classification','d.status','d.malware_scan_status','d.sha256','d.malware_scanned_sha256','d.rights_status','link.payload','link.deleted_at']) : null; - if (! $row) $this->fail('evidence', 'Documento não associado à ficha neste âmbito; reutilize o gestor documental existente.'); + $row = $record?->exists ? DB::table('opportunity_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id') + ->where('link.opportunity_id', $record->id)->where('d.id', $id)->whereNull('link.deleted_at')->first(['d.data_classification', 'd.status', 'd.malware_scan_status', 'd.sha256', 'd.malware_scanned_sha256', 'd.rights_status', 'link.payload', 'link.deleted_at']) : null; + if (! $row) { + $this->fail('evidence', 'Documento não associado à ficha neste âmbito; reutilize o gestor documental existente.'); + } if ($public && ($row->data_classification !== 'public' || $row->status !== 'active' || $row->malware_scan_status !== 'clean' || $row->sha256 !== $row->malware_scanned_sha256 || $row->rights_status === 'restricted' || data_get(json_decode($row->payload, true), 'visibility') !== 'public')) { $this->fail('evidence', 'Documento interno, restrito ou sem disponibilização pública: preserve a evidência interna, sem a divulgar.'); } } - /** No private field is copied into the public summary automatically. */ + /** No private field is copied into the public summary automatically. + * @param array $input + * @return array|null + */ public function publication(Opportunity $record, array $input): ?array { $private = app(OpportunityManagement::class)->values($record)['proactive'] ?? null; if (! $private) { - if (isset($input['proactive_public'])) $this->fail('publication', 'Prepare a análise antes de divulgar esta síntese.'); + if (isset($input['proactive_public'])) { + $this->fail('publication', 'Prepare a análise antes de divulgar esta síntese.'); + } + return null; } $d = Validator::make($input, [ 'proactive_reviewed' => 'required|accepted', 'proactive_public' => 'required|array:group,state,need,proposal,basis,limitations,next_action,evidence_ids,chain_id,stage,resources,assumptions,conditions,relation_ids', 'proactive_public.group' => 'required|in:exists,needs,ideas', - 'proactive_public.resources'=>'nullable|string|max:2500', 'proactive_public.assumptions'=>'nullable|string|max:2500', 'proactive_public.conditions'=>'nullable|string|max:2500', - 'proactive_public.relation_ids'=>'sometimes|array|max:40', 'proactive_public.relation_ids.*'=>'uuid|distinct', + 'proactive_public.resources' => 'nullable|string|max:2500', 'proactive_public.assumptions' => 'nullable|string|max:2500', 'proactive_public.conditions' => 'nullable|string|max:2500', + 'proactive_public.relation_ids' => 'sometimes|array|max:40', 'proactive_public.relation_ids.*' => 'uuid|distinct', 'proactive_public.state' => 'required|in:hypothesis,confirmed', 'proactive_public.chain_id' => 'nullable|uuid', 'proactive_public.stage' => 'nullable|string|max:1000', - 'proactive_public.need'=>'required_unless:proactive_public.group,exists|nullable|string|max:2500', 'proactive_public.proposal'=>'required_if:proactive_public.group,ideas|nullable|string|max:2500', - ...collect(['basis','limitations','next_action'])->mapWithKeys(fn ($k) => ['proactive_public.'.$k => 'required|string|max:2500'])->all(), + 'proactive_public.need' => 'required_unless:proactive_public.group,exists|nullable|string|max:2500', 'proactive_public.proposal' => 'required_if:proactive_public.group,ideas|nullable|string|max:2500', + ...collect(['basis', 'limitations', 'next_action'])->mapWithKeys(fn ($k) => ['proactive_public.'.$k => 'required|string|max:2500'])->all(), 'proactive_public.evidence_ids' => 'present|array|max:60', 'proactive_public.evidence_ids.*' => 'uuid|distinct', ])->validate()['proactive_public']; - $d += ['need'=>'','proposal'=>'']; - if ($d['group'] !== $private['kind']) $this->fail('publication', 'O grupo público deve corresponder à análise guardada.'); + $d += ['need' => '', 'proposal' => '']; + if ($d['group'] !== $private['kind']) { + $this->fail('publication', 'O grupo público deve corresponder à análise guardada.'); + } $proofs = []; foreach ($d['evidence_ids'] as $id) { - $e = collect($private['evidence'])->firstWhere('id', $id); - if (! $e || $e['access'] !== 'public') $this->fail('publication', 'Não pode divulgar evidência interna nem a sua fonte indirectamente. Redija uma síntese independente ou seleccione evidência pública autorizada.'); - if (! empty($e['document_id'])) $this->document($record, $e['document_id'], true); - $proofs[] = array_intersect_key($e, array_flip(['id','category','purpose','statement','source','locator','period','scope','coverage','verified_on'])); + $e = collect(StructuredData::collectionItems($private['evidence']))->firstWhere('id', $id); + if (! $e || $e['access'] !== 'public') { + $this->fail('publication', 'Não pode divulgar evidência interna nem a sua fonte indirectamente. Redija uma síntese independente ou seleccione evidência pública autorizada.'); + } + if (! empty($e['document_id'])) { + $this->document($record, $e['document_id'], true); + } + $proofs[] = array_intersect_key($e, array_flip(['id', 'category', 'purpose', 'statement', 'source', 'locator', 'period', 'scope', 'coverage', 'verified_on'])); } - if ($d['group'] !== 'exists' && $d['state'] === 'confirmed' && ($private['need']['state'] !== 'confirmed' || array_diff($private['need']['evidence_ids'], $d['evidence_ids']))) $this->fail('publication', 'Confirmação pública exige a necessidade confirmada e o fundamento público seleccionado; pode divulgar uma hipótese com limitações explícitas.'); - if (!empty($d['chain_id']) && !app(OpportunityClassification::class)->targets('chain')->where('id', $d['chain_id'])->exists()) $this->fail('publication', 'Cadeia de valor pública indisponível.'); - $relations=[]; + if ($d['group'] !== 'exists' && $d['state'] === 'confirmed' && ($private['need']['state'] !== 'confirmed' || array_diff($private['need']['evidence_ids'], $d['evidence_ids']))) { + $this->fail('publication', 'Confirmação pública exige a necessidade confirmada e o fundamento público seleccionado; pode divulgar uma hipótese com limitações explícitas.'); + } + if (! empty($d['chain_id']) && ! app(OpportunityClassification::class)->targets('chain')->where('id', $d['chain_id'])->exists()) { + $this->fail('publication', 'Cadeia de valor pública indisponível.'); + } + $relations = []; foreach ($d['relation_ids'] ?? [] as $id) { - $r=collect($private['relations'])->firstWhere('id',$id); - if (!$r || array_diff($r['evidence_ids'],$d['evidence_ids'])) $this->fail('publication','A relação exige o fundamento público seleccionado; nenhuma referência interna é copiada.'); - $target=app(OpportunityClassification::class)->targets($r['kind'])->where('id',$r['target_id'])->first(['id',OpportunityClassification::TARGETS[$r['kind']][1].' as name']); - if (!$target) $this->fail('publication','Referência pública da relação indisponível.'); - $relations[]=['kind'=>$r['kind'],'target_id'=>$r['target_id'],'label'=>$target->name,'relationship'=>$r['relation'],'evidence_ids'=>$r['evidence_ids'],'basis'=>$d['basis']]; + $r = collect(StructuredData::collectionItems($private['relations']))->firstWhere('id', $id); + if (! $r || array_diff($r['evidence_ids'], $d['evidence_ids'])) { + $this->fail('publication', 'A relação exige o fundamento público seleccionado; nenhuma referência interna é copiada.'); + } + $target = app(OpportunityClassification::class)->targets($r['kind'])->where('id', $r['target_id'])->first(['id', OpportunityClassification::TARGETS[$r['kind']][1].' as name']); + if (! $target) { + $this->fail('publication', 'Referência pública da relação indisponível.'); + } + $relations[] = ['kind' => $r['kind'], 'target_id' => $r['target_id'], 'label' => $target->name, 'relationship' => $r['relation'], 'evidence_ids' => $r['evidence_ids'], 'basis' => $d['basis']]; } // Catch literal reuse of private statements, interpretations, references and document identities. $publicText = mb_strtolower(implode(' ', [...array_filter($input, 'is_scalar'), ...array_filter($d, 'is_scalar')])); foreach ($private['evidence'] as $e) { - if ($e['access'] === 'public') continue; - foreach (['statement','interpretation','source','locator','document_id'] as $key) { + if ($e['access'] === 'public') { + continue; + } + foreach (['statement', 'interpretation', 'source', 'locator', 'document_id'] as $key) { $value = trim($e[$key] ?? ''); - if (mb_strlen($value) >= 10 && str_contains($publicText, mb_strtolower($value))) $this->fail('publication', 'O resumo contém informação de evidência interna. Reveja a síntese e a divulgação indirecta antes de publicar.'); + if (mb_strlen($value) >= 10 && str_contains($publicText, mb_strtolower($value))) { + $this->fail('publication', 'O resumo contém informação de evidência interna. Reveja a síntese e a divulgação indirecta antes de publicar.'); + } } } + return [...array_map(fn ($v) => is_string($v) ? trim(strip_tags($v)) : $v, $d), - 'version' => self::VERSION, 'relations'=>$relations, 'evidence' => $proofs, 'notice' => self::NOTICE, + 'version' => self::VERSION, 'relations' => $relations, 'evidence' => $proofs, 'notice' => self::NOTICE, 'availability_confirmed' => false, 'economic_validation' => false]; } + /** + * @return array|null + */ public function publicData(Opportunity $record): ?array { $p = data_get($record->workflow_context, 'promotional_summary.projection.proactive'); + return is_array($p) && Opportunity::published()->whereKey($record->id)->exists() ? $p : null; } + /** + * @return array + */ public function publicationRelations(Opportunity $record): array { - return collect(app(OpportunityManagement::class)->values($record)['proactive']['relations'] ?? [])->map(function($r){ - $label=app(OpportunityClassification::class)->targets($r['kind'])->where('id',$r['target_id'])->value(OpportunityClassification::TARGETS[$r['kind']][1]); - return $label?['id'=>$r['id'],'label'=>$label,'relationship'=>$r['relation']]:null; + return collect(StructuredData::collectionItems(app(OpportunityManagement::class)->values($record)['proactive']['relations'] ?? []))->map(function ($r) { + $label = app(OpportunityClassification::class)->targets($r['kind'])->where('id', $r['target_id'])->value(OpportunityClassification::TARGETS[$r['kind']][1]); + + return $label ? ['id' => $r['id'], 'label' => $label, 'relationship' => $r['relation']] : null; })->filter()->values()->all(); } + /** + * @return array + */ public function history(?Opportunity $record): array { - if (!$record) return []; + if (! $record) { + return []; + } + return DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->orderByRaw("(after_data->>'version')::integer DESC")->limit(30)->get()->map(function ($event) { - $entry=json_decode($event->after_data,true);$snapshot=json_decode(Crypt::decryptString($entry['snapshot']),true); - $draft=json_decode($snapshot['management_draft'] ?? '{}',true);$need=$draft['proactive']['need'] ?? null; - return $need ? ['at'=>$event->occurred_at,'version'=>$entry['version'],'state'=>$need['state'],'problem'=>$need['problem'],'confirmation'=>$need['confirmation'],'evidence_ids'=>$need['evidence_ids']] : null; + $entry = json_decode($event->after_data, true); + $snapshot = json_decode(Crypt::decryptString($entry['snapshot']), true); + $draft = json_decode($snapshot['management_draft'] ?? '{}', true); + $need = $draft['proactive']['need'] ?? null; + + return $need ? ['at' => $event->occurred_at, 'version' => $entry['version'], 'state' => $need['state'], 'problem' => $need['problem'], 'confirmation' => $need['confirmation'], 'evidence_ids' => $need['evidence_ids']] : null; })->filter()->values()->all(); } + /** + * @return array + */ public function options(?Opportunity $record = null): array { - return ['documents' => $record ? DB::table('opportunity_documents as l')->join('documents as d','d.id','=','l.document_id')->where('l.opportunity_id',$record->id)->whereNull('l.deleted_at')->whereNull('d.deleted_at')->get(['d.id','d.file_name as title'])->all() : [], 'groups' => self::GROUPS, 'references' => collect(['project','cluster','corridor','park','chain'])->mapWithKeys(fn ($kind) => [$kind => app(OpportunityClassification::class)->targets($kind)->orderBy(OpportunityClassification::TARGETS[$kind][1])->limit(300)->get(['id', OpportunityClassification::TARGETS[$kind][1].' as name'])])->all()]; + return ['documents' => $record ? DB::table('opportunity_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.opportunity_id', $record->id)->whereNull('l.deleted_at')->whereNull('d.deleted_at')->get(['d.id', 'd.file_name as title'])->all() : [], 'groups' => self::GROUPS, 'references' => collect(['project', 'cluster', 'corridor', 'park', 'chain'])->mapWithKeys(fn ($kind) => [$kind => app(OpportunityClassification::class)->targets($kind)->orderBy(OpportunityClassification::TARGETS[$kind][1])->limit(300)->get(['id', OpportunityClassification::TARGETS[$kind][1].' as name'])])->all()]; } private function fail(string $key, string $message): never diff --git a/app/Services/ProactiveSupply.php b/app/Services/ProactiveSupply.php index 85bb1a8d..a869b0fa 100644 --- a/app/Services/ProactiveSupply.php +++ b/app/Services/ProactiveSupply.php @@ -7,7 +7,9 @@ use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; /** Internal observations, never a public business directory or proof of demand. */ final class ProactiveSupply @@ -16,85 +18,118 @@ final class ProactiveSupply public function authorize(User $actor, ?Opportunity $record = null): void { - $access=app(PortalProfileAccess::class); - $internal=collect(OpportunityManagement::CATEGORIES)->contains(fn($type)=>$access->internalAssignment($actor,$type)!==null); - abort_unless($access->legacyWriteAllowed($actor)||$internal,403); - if ($record?->exists) $access->authorize($actor,'view',$record); + $access = app(PortalProfileAccess::class); + $internal = collect(OpportunityManagement::CATEGORIES)->contains(fn ($type) => $access->internalAssignment($actor, $type) !== null); + abort_unless($access->legacyWriteAllowed($actor) || $internal, 403); + if ($record?->exists) { + $access->authorize($actor, 'view', $record); + } } private function read(User $actor, callable $callback): mixed { - return DB::transaction(function()use($actor,$callback){ - DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read','internal',true)",[(string)$actor->id]); + return DB::transaction(function () use ($actor, $callback) { + DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read','internal',true)", [(string) $actor->id]); + return $callback(); }); } - public function search(User $actor,array $input,?Opportunity $record=null): array + /** + * @param array $input + * @return array + */ + public function search(User $actor, array $input, ?Opportunity $record = null): array { - $this->authorize($actor,$record); - $v=Validator::make($input,[ - 'territory_id'=>['required','uuid',Rule::exists('geo_locations','id')->whereNull('deleted_at')], - 'activity'=>'nullable|string|max:150','sector'=>'nullable|string|max:250','page'=>'nullable|integer|min:1|max:100000', + $this->authorize($actor, $record); + $v = Validator::make($input, [ + 'territory_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')], + 'activity' => 'nullable|string|max:150', 'sector' => 'nullable|string|max:250', 'page' => 'nullable|integer|min:1|max:100000', ])->validate(); - if(!Schema::hasTable('proactive_supply_observations'))return ['installed'=>false,'rows'=>[],'total'=>null,'limitations'=>self::LIMITATIONS,'status'=>'Base interna ainda não carregada neste ambiente.']; - return $this->read($actor,function()use($v){ - $ids=[$v['territory_id']]; + if (! Schema::hasTable('proactive_supply_observations')) { + return ['installed' => false, 'rows' => [], 'total' => null, 'limitations' => self::LIMITATIONS, 'status' => 'Base interna ainda não carregada neste ambiente.']; + } + + return $this->read($actor, function () use ($v) { + $ids = [$v['territory_id']]; // Canonical descendants, not a name-only cross-province match. - $front=$ids; - for($depth=0;$depth<5&&$front;$depth++){ - $front=DB::table('geo_locations')->whereIn('parent_id',$front)->whereNull('deleted_at')->pluck('id')->all(); - $ids=[...$ids,...$front]; + $front = $ids; + for ($depth = 0; $depth < 5 && $front; $depth++) { + $front = DB::table('geo_locations')->whereIn('parent_id', $front)->whereNull('deleted_at')->pluck('id')->all(); + $ids = [...$ids, ...$front]; } - $base=DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s','s.sha256','=','o.source_hash')->whereIn('o.territory_id',$ids); - $sectors=(clone $base)->where('o.sector','<>','')->distinct()->orderBy('o.sector')->pluck('o.sector')->all(); - if(!empty($v['sector']))$base->where('o.sector',$v['sector']); - if(!empty($v['activity']))$base->where(fn($q)=>$q->where('o.cae','ilike','%'.$v['activity'].'%')->orWhereRaw("translate(lower(o.activity),'áàâãéêíóôõúç','aaaaeeiooouc') LIKE ?",['%'.strtolower(\Illuminate\Support\Str::ascii($v['activity'])).'%'])); - $page=$v['page']??1; + $base = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')->whereIn('o.territory_id', $ids); + $sectors = (clone $base)->where('o.sector', '<>', '')->distinct()->orderBy('o.sector')->pluck('o.sector')->all(); + if (! empty($v['sector'])) { + $base->where('o.sector', $v['sector']); + } + if (! empty($v['activity'])) { + $base->where(fn ($q) => $q->where('o.cae', 'ilike', '%'.$v['activity'].'%')->orWhereRaw("translate(lower(o.activity),'áàâãéêíóôõúç','aaaaeeiooouc') LIKE ?", ['%'.strtolower(Str::ascii($v['activity'])).'%'])); + } + $page = $v['page'] ?? 1; // Exact repeated observations are retained in storage, counted separately. - $total=(clone $base)->whereNull('o.duplicate_of')->count(); - $duplicates=(clone $base)->whereNotNull('o.duplicate_of')->count(); - $rows=(clone $base)->whereNull('o.duplicate_of')->orderBy('o.name')->orderBy('o.id')->offset(($page-1)*25)->limit(25)->get(['o.id','o.entity_key','o.name','o.province','o.district','o.territory_id','o.territory_resolution','o.cae','o.activity','o.sector','o.source_date','o.sheet','o.row_number','o.source_hash','o.identity_conflict','s.name as source_name','s.period as source_period','s.imported_at']); - $coverage=DB::table('proactive_supply_observations')->selectRaw("count(*) FILTER (WHERE territory_id IS NULL) AS unmapped, count(*) FILTER (WHERE territory_resolution='province_only_district_unknown') AS province_only")->first(); - return ['installed'=>true,'rows'=>$rows,'total'=>$total,'duplicate_sightings'=>$duplicates,'page'=>$page,'per_page'=>25,'sectors'=>$sectors,'territory_coverage'=>$coverage,'limitations'=>self::LIMITATIONS,'status'=>$total?'Registos documentais encontrados; não equivalem a empresas únicas ou oferta actual confirmada.':'Informação insuficiente nesta base e nestes filtros.']; + $total = (clone $base)->whereNull('o.duplicate_of')->count(); + $duplicates = (clone $base)->whereNotNull('o.duplicate_of')->count(); + $rows = (clone $base)->whereNull('o.duplicate_of')->orderBy('o.name')->orderBy('o.id')->offset(($page - 1) * 25)->limit(25)->get(['o.id', 'o.entity_key', 'o.name', 'o.province', 'o.district', 'o.territory_id', 'o.territory_resolution', 'o.cae', 'o.activity', 'o.sector', 'o.source_date', 'o.sheet', 'o.row_number', 'o.source_hash', 'o.identity_conflict', 's.name as source_name', 's.period as source_period', 's.imported_at']); + $coverage = DB::table('proactive_supply_observations')->selectRaw("count(*) FILTER (WHERE territory_id IS NULL) AS unmapped, count(*) FILTER (WHERE territory_resolution='province_only_district_unknown') AS province_only")->first(); + + return ['installed' => true, 'rows' => $rows, 'total' => $total, 'duplicate_sightings' => $duplicates, 'page' => $page, 'per_page' => 25, 'sectors' => $sectors, 'territory_coverage' => $coverage, 'limitations' => self::LIMITATIONS, 'status' => $total ? 'Registos documentais encontrados; não equivalem a empresas únicas ou oferta actual confirmada.' : 'Informação insuficiente nesta base e nestes filtros.']; }); } - /** Resolve server-side source lineage; a caller cannot fabricate an internal observation. */ - public function comparisons(User $actor,array $input,?Opportunity $record): array + /** Resolve server-side source lineage; a caller cannot fabricate an internal observation. + * @param array $input + * @return array + */ + public function comparisons(User $actor, array $input, ?Opportunity $record): array { - $this->authorize($actor,$record); - $v=Validator::make(['rows'=>$input],[ - 'rows'=>'array|max:40','rows.*'=>'array:observation_id,relationship,rationale,limitations', - 'rows.*.observation_id'=>'required|string|size:64|distinct', - 'rows.*.relationship'=>'required|in:competition,complementarity,partner,uncertain', - 'rows.*.rationale'=>'required|string|max:4000','rows.*.limitations'=>'required|string|max:4000', + $this->authorize($actor, $record); + $v = Validator::make(['rows' => $input], [ + 'rows' => 'array|max:40', 'rows.*' => 'array:observation_id,relationship,rationale,limitations', + 'rows.*.observation_id' => 'required|string|size:64|distinct', + 'rows.*.relationship' => 'required|in:competition,complementarity,partner,uncertain', + 'rows.*.rationale' => 'required|string|max:4000', 'rows.*.limitations' => 'required|string|max:4000', ])->validate()['rows']; - if(!$v)return []; - abort_unless(Schema::hasTable('proactive_supply_observations'),422,'Base interna indisponível.'); - return $this->read($actor,function()use($v){ - $fingerprints=[]; - foreach($v as $row){ - $o=DB::table('proactive_supply_observations')->where('id',$row['observation_id'])->first(); - if(!$o)throw \Illuminate\Validation\ValidationException::withMessages(['proactive.supply_comparisons'=>'Registo da oferta indisponível; preserve a referência anterior e confira o ambiente.']); - if($o->duplicate_of!==null||isset($fingerprints[$o->fingerprint]))throw \Illuminate\Validation\ValidationException::withMessages(['proactive.supply_comparisons'=>'Esta observação administrativa é repetida. Compare a referência original uma única vez; a repetição e a sua fonte permanecem conservadas na base interna.']); - $fingerprints[$o->fingerprint]=true; + if (! $v) { + return []; + } + abort_unless(Schema::hasTable('proactive_supply_observations'), 422, 'Base interna indisponível.'); + + return $this->read($actor, function () use ($v) { + $fingerprints = []; + foreach ($v as $row) { + $o = DB::table('proactive_supply_observations')->where('id', $row['observation_id'])->first(); + if (! $o) { + throw ValidationException::withMessages(['proactive.supply_comparisons' => 'Registo da oferta indisponível; preserve a referência anterior e confira o ambiente.']); + } + if ($o->duplicate_of !== null || isset($fingerprints[$o->fingerprint])) { + throw ValidationException::withMessages(['proactive.supply_comparisons' => 'Esta observação administrativa é repetida. Compare a referência original uma única vez; a repetição e a sua fonte permanecem conservadas na base interna.']); + } + $fingerprints[$o->fingerprint] = true; } + return $v; }); } - public function evidence(User $actor,string $id,?Opportunity $record): array + /** + * @return array + */ + public function evidence(User $actor, string $id, ?Opportunity $record): array { - $this->authorize($actor,$record); - return $this->read($actor,function()use($id){ - $o=DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s','s.sha256','=','o.source_hash')->where('o.id',$id)->first(['o.*','s.name as source_name','s.period as source_period']); - if(!$o)throw \Illuminate\Validation\ValidationException::withMessages(['proactive.evidence'=>'Observação interna indisponível neste ambiente.']); - return ['observation_id'=>$o->id,'category'=>'supply','purpose'=>'local','access'=>'internal','document_id'=>null, - 'statement'=>'Registo administrativo: '.$o->name.'; CAE '.$o->cae.'; '.($o->activity?:'actividade por classificar').'; '.$o->district.', '.$o->province.'. Não confirma actividade actual.', - 'source'=>$o->source_name.'; SHA256 '.$o->source_hash,'locator'=>$o->sheet.'!linha '.$o->row_number, - 'period'=>$o->source_date.'; '.$o->source_period,'scope'=>$o->district.', '.$o->province, - 'coverage'=>self::LIMITATIONS.($o->identity_conflict?' Identificador partilhado por denominações divergentes: identidade por conferir.':''), + $this->authorize($actor, $record); + + return $this->read($actor, function () use ($id) { + $o = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')->where('o.id', $id)->first(['o.*', 's.name as source_name', 's.period as source_period']); + if (! $o) { + throw ValidationException::withMessages(['proactive.evidence' => 'Observação interna indisponível neste ambiente.']); + } + + return ['observation_id' => $o->id, 'category' => 'supply', 'purpose' => 'local', 'access' => 'internal', 'document_id' => null, + 'statement' => 'Registo administrativo: '.$o->name.'; CAE '.$o->cae.'; '.($o->activity ?: 'actividade por classificar').'; '.$o->district.', '.$o->province.'. Não confirma actividade actual.', + 'source' => $o->source_name.'; SHA256 '.$o->source_hash, 'locator' => $o->sheet.'!linha '.$o->row_number, + 'period' => $o->source_date.'; '.$o->source_period, 'scope' => $o->district.', '.$o->province, + 'coverage' => self::LIMITATIONS.($o->identity_conflict ? ' Identificador partilhado por denominações divergentes: identidade por conferir.' : ''), ]; }); } diff --git a/app/Services/Procurement.php b/app/Services/Procurement.php index 8960f5ea..98dad432 100644 --- a/app/Services/Procurement.php +++ b/app/Services/Procurement.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Opportunity; use App\Models\User; +use App\Support\CheckedPattern; use Carbon\CarbonImmutable; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; @@ -38,6 +39,9 @@ final class Procurement abort_unless($this->access($actor), 403, 'Use a autorização editorial já atribuída.'); } + /** + * @return array + */ public function references(User $actor): array { $this->guard($actor); @@ -54,13 +58,16 @@ final class Procurement ->orderBy('r.title')->get(['r.id', 'r.title', 'r.published', 'd.malware_scan_status'])]; } - public function record(string $id): object + public function record(string $id): \stdClass { abort_unless($this->available(), 404); return DB::table('portal_tenders')->where('id', $id)->firstOrFail(); } + /** + * @param array $input + */ public function save(User $actor, ?string $id, array $input): string { $this->guard($actor); @@ -84,6 +91,10 @@ final class Procurement }); } + /** + * @param array $input + * @return array + */ private function normalize(User $actor, array $input): array { $rules = array_fill_keys(self::TEXT, 'nullable|string|max:12000'); @@ -110,6 +121,9 @@ final class Procurement return $data; } + /** + * @return array + */ public function preview(User $actor, string $id, string $act): array { $this->guard($actor); @@ -119,7 +133,7 @@ final class Procurement Validator::make($p, array_fill_keys(['title', 'reference', 'organization_id', 'modality', 'object', 'regime', 'instrument', 'edition', 'rule_source', 'origin_published_at', 'deadline', 'delivery_channel', 'timezone'], 'required'))->validate(); Validator::make($p, ['timezone' => 'timezone', 'object_kind' => 'nullable|in:works,goods,services,mixed,other', 'guarantee_status' => 'nullable|in:required,not_required,unknown'])->validate(); Validator::make($p, ['public_reason' => 'required|string|min:20|max:1000'])->validate(); - $dates = []; + $dates = ['origin_published_at' => null, 'clarifications_at' => null, 'deadline' => null, 'opening_at' => null]; foreach (['origin_published_at', 'clarifications_at', 'deadline', 'opening_at'] as $field) { if (empty($p[$field])) { continue; @@ -148,7 +162,7 @@ final class Procurement $this->require(! empty($p['minimum_calendar_source']), 'minimum_calendar_source', 'Identifique a fonte do calendário de dias úteis e feriados.'); $weekdays = array_filter(array_map('trim', explode(',', (string) ($p['minimum_working_days'] ?? '')))); $this->require(count($weekdays) > 0 && ! array_diff($weekdays, ['1', '2', '3', '4', '5', '6', '7']), 'minimum_working_days', 'Indique os dias de trabalho documentados (1=segunda, 7=domingo).'); - $holidays = array_values(array_filter(preg_split('/[\s,;]+/', (string) ($p['minimum_holidays'] ?? '')))); + $holidays = array_values(array_filter(CheckedPattern::split('/[\s,;]+/', (string) ($p['minimum_holidays'] ?? '')))); Validator::make(['dates' => $holidays], ['dates' => 'array|max:1000', 'dates.*' => 'date_format:Y-m-d'])->validate(); for ($left = (int) $p['minimum_days'],$iterations = 0; $left > 0; $iterations++) { $this->require($iterations < 36500, 'minimum_days', 'Calendário insuficiente para calcular o prazo indicado.'); @@ -222,6 +236,9 @@ final class Procurement return $plan; } + /** + * @param array $input + */ public function publish(User $actor, string $id, array $input): void { $this->guard($actor); @@ -237,6 +254,9 @@ final class Procurement }); } + /** + * @param array $payload + */ private function version(User $actor, string $id, int $version, string $act, array $payload, string $reason, bool $published): string { $vid = (string) Str::uuid(); @@ -254,6 +274,9 @@ final class Procurement ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_images as oi')->whereColumn('oi.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(oi.payload,'{}'::jsonb),'library_source')")); } + /** + * @return array + */ public function publishedVersion(string $id): array { $v = DB::table('portal_tender_versions')->where('id', $id)->whereNotNull('published_at')->firstOrFail(); @@ -265,6 +288,9 @@ final class Procurement return ['id' => $v->id, 'version' => $v->version, 'act' => $v->act, 'payload' => $p, 'published_at' => $v->published_at, 'reason' => $v->reason]; } + /** + * @param array $version + */ public function state(array $version): string { $p = $version['payload']; diff --git a/app/Services/PublicDocumentPresentation.php b/app/Services/PublicDocumentPresentation.php index 73519061..ee13a851 100644 --- a/app/Services/PublicDocumentPresentation.php +++ b/app/Services/PublicDocumentPresentation.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Support\CheckedPattern; use Illuminate\Support\Facades\DB; /** Public projection only. Management retains the complete stored records and links. */ @@ -19,28 +20,38 @@ final class PublicDocumentPresentation private function text(?string $value): ?string { - if ($value === null) return null; + if ($value === null) { + return null; + } // Imported descriptions can contain implementation notes. Never mutate the original. - $sentences = preg_split('/(?<=[.!?])\s+|[\r\n]+/u', strip_tags($value)); - $safe = array_filter($sentences, fn ($text) => !preg_match('~(?:\b(?:supports|cites|evidence)\b|\btheme\s*:|\bfiscal\.[\w.]+|\b(?:SHA-?256|hash|preflight|repositório|autenticidade|comparação binária|estado de vigência|revisão de direitos)\b|Cópia mantida em privado|\b[0-9a-f]{40,64}\b|(?:^|\s)(?:[A-Z]:[\\/]|(?:docs|outputs|resources|storage|app)/))~iu', $text)); + $sentences = CheckedPattern::split('/(?<=[.!?])\s+|[\r\n]+/u', strip_tags($value)); + $safe = array_filter($sentences, fn ($text) => ! preg_match('~(?:\b(?:supports|cites|evidence)\b|\btheme\s*:|\bfiscal\.[\w.]+|\b(?:SHA-?256|hash|preflight|repositório|autenticidade|comparação binária|estado de vigência|revisão de direitos)\b|Cópia mantida em privado|\b[0-9a-f]{40,64}\b|(?:^|\s)(?:[A-Z]:[\\/]|(?:docs|outputs|resources|storage|app)/))~iu', $text)); $result = trim(implode(' ', $safe)); + return $result !== '' ? $result : null; } + /** + * @return array + */ public function resource(object $row): array { - $r=(array)$row; - $public = array_intersect_key($r, array_flip(['id','document_type','document_date','applicable_from','applicable_until','language','territorial_scope','territory_name','source_confirmation','legal_status','size_bytes'])); - foreach (['title','description','issuer','document_number','category','topic','version_label'] as $key) $public[$key]=$this->text($r[$key]??null); + $r = (array) $row; + $public = array_intersect_key($r, array_flip(['id', 'document_type', 'document_date', 'applicable_from', 'applicable_until', 'language', 'territorial_scope', 'territory_name', 'source_confirmation', 'legal_status', 'size_bytes'])); + foreach (['title', 'description', 'issuer', 'document_number', 'category', 'topic', 'version_label'] as $key) { + $public[$key] = $this->text($r[$key] ?? null); + } $public['title'] ??= 'Documento'; - $url=$r['source_url']??null; - $public['source_url']=is_string($url)&&filter_var($url,FILTER_VALIDATE_URL)&&in_array(strtolower(parse_url($url,PHP_URL_SCHEME)??''),['https','http'],true)?$url:null; - $public['download_url']=route('services.download',['id'=>$r['id']]); - $public['view_url']=in_array($r['media_type']??null,['application/pdf','image/jpeg','image/png','image/webp'],true)?route('services.document.view',['id'=>$r['id']]):null; - $public['usage_notice']=in_array($r['document_type']??'', ['legislation','procedure','official_form','digital_adaptation','guidance_template'],true) + $url = $r['source_url'] ?? null; + $scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null; + $public['source_url'] = is_string($url) && filter_var($url, FILTER_VALIDATE_URL) && is_string($scheme) && in_array(strtolower($scheme), ['https', 'http'], true) ? $url : null; + $public['download_url'] = route('services.download', ['id' => $r['id']]); + $public['view_url'] = in_array($r['media_type'] ?? null, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true) ? route('services.document.view', ['id' => $r['id']]) : null; + $public['usage_notice'] = in_array($r['document_type'] ?? '', ['legislation', 'procedure', 'official_form', 'digital_adaptation', 'guidance_template'], true) ? 'Confirme no documento ou junto da entidade competente as condições aplicáveis ao seu caso. A consulta não garante benefícios nem aplicação fiscal.' : null; - $keys=DB::table('portal_resource_links')->where('resource_id',$r['id'])->where('target_type','module')->pluck('target_key'); - $public['context_actions']=$keys->filter(fn($key)=>isset(self::ACTIONS[$key]))->map(fn($key)=>self::ACTIONS[$key])->unique('href')->values()->all(); + $keys = DB::table('portal_resource_links')->where('resource_id', $r['id'])->where('target_type', 'module')->pluck('target_key'); + $public['context_actions'] = $keys->filter(fn ($key) => isset(self::ACTIONS[$key]))->map(fn ($key) => self::ACTIONS[$key])->unique('href')->values()->all(); + return $public; } } diff --git a/app/Services/RetainedGeoJsonCollection.php b/app/Services/RetainedGeoJsonCollection.php index a7462b9b..14ee3caa 100644 --- a/app/Services/RetainedGeoJsonCollection.php +++ b/app/Services/RetainedGeoJsonCollection.php @@ -36,7 +36,11 @@ final class RetainedGeoJsonCollection $position = 0; $peek = static function () use ($stream, &$buffer, &$position): ?string { if ($position === strlen($buffer)) { - $buffer = fread($stream, 65536); + $chunk = fread($stream, 65536); + if ($chunk === false) { + throw new \RuntimeException('Não foi possível ler o exemplar GeoJSON; original preservado.'); + } + $buffer = $chunk; $position = 0; } @@ -153,7 +157,7 @@ final class RetainedGeoJsonCollection } $expect('}'); $white(); - abort_unless($peek() === null && $hadFeatures,422,'Exemplar incompleto ou com conteúdo adicional.'); + abort_unless($peek() === null && $hadFeatures, 422, 'Exemplar incompleto ou com conteúdo adicional.'); return $metadata; } finally { diff --git a/app/Services/ScopedPortalAdministration.php b/app/Services/ScopedPortalAdministration.php index d144c146..8d998a41 100644 --- a/app/Services/ScopedPortalAdministration.php +++ b/app/Services/ScopedPortalAdministration.php @@ -1,8 +1,10 @@ hasVerifiedEmail()) return false; + if (! $actor || ! $actor->hasVerifiedEmail()) { + return false; + } $access = app(PortalProfileAccess::class); - if ($access->has($actor, 'auditor')) return false; + if ($access->has($actor, 'auditor')) { + return false; + } + return ($access->superuser($actor) && $access->mfa($actor)) || ($access->legacyWriteAllowed($actor) && $access->mfa($actor)); } public function interestOperator(?User $actor): bool { - if (!$actor || !$actor->hasVerifiedEmail()) return false; - $access=app(PortalProfileAccess::class); - return !$access->has($actor,'auditor') && $access->mfa($actor) - && ($this->administrative($actor) || DB::table('interest_requests')->where('assigned_to',$actor->id)->whereNull('deleted_at')->exists()); + if (! $actor || ! $actor->hasVerifiedEmail()) { + return false; + } + $access = app(PortalProfileAccess::class); + + return ! $access->has($actor, 'auditor') && $access->mfa($actor) + && ($this->administrative($actor) || DB::table('interest_requests')->where('assigned_to', $actor->id)->whereNull('deleted_at')->exists()); } public function interest(User $actor, InterestRequest $record): bool { return $this->interestOperator($actor) - && ($actor->can('manage-portal') || (int)$record->assigned_to===$actor->id); + && ($actor->can('manage-portal') || (int) $record->assigned_to === $actor->id); } - public function interestQuery($query, User $actor): void + /** @param Builder|\Illuminate\Database\Query\Builder $query */ + public function interestQuery(Builder|\Illuminate\Database\Query\Builder $query, User $actor): void { - if (!$actor->can('manage-portal')) $query->where('assigned_to',$actor->id); + if (! $actor->can('manage-portal')) { + $query->where('assigned_to', $actor->id); + } } - public function deskDecision(User $actor, object $request): bool + public function deskDecision(User $actor, \stdClass $request): bool { return $actor->can('manage-portal') - || ($this->administrative($actor) && (int)$request->analyst_id===$actor->id); + || ($this->administrative($actor) && (int) $request->analyst_id === $actor->id); } public function deskCaseDecision(User $actor, string $id): bool { return $actor->can('manage-portal') || ($this->administrative($actor) - && DB::table('desk_requests')->where('case_id',$id)->where('analyst_id',$actor->id)->exists()); + && DB::table('desk_requests')->where('case_id', $id)->where('analyst_id', $actor->id)->exists()); } } diff --git a/app/Services/SectorInvestment.php b/app/Services/SectorInvestment.php index 1c6936d6..c4ce0bec 100644 --- a/app/Services/SectorInvestment.php +++ b/app/Services/SectorInvestment.php @@ -6,6 +6,7 @@ use App\Http\Controllers\InvestmentPortfolioController; use App\Models\Opportunity; use App\Models\Sector; use App\Models\User; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -27,6 +28,9 @@ final class SectorInvestment return Sector::where('slug', $slug)->where('is_active', true)->firstOrFail(); } + /** + * @return array + */ public function context(string $slug): array { $sector = $this->sector($slug); @@ -34,7 +38,9 @@ final class SectorInvestment return [...$sector->only(['id', 'name', 'slug']), 'href' => SectorOrganization::href($sector->slug)]; } - /** Public detail shared by the catalogue and the individual sector page. */ + /** Public detail shared by the catalogue and the individual sector page. + * @return array + */ public function page(Sector $sector): array { return [ @@ -45,6 +51,9 @@ final class SectorInvestment ]; } + /** + * @return array + */ public function publicView(Sector $sector): array { $creation = app(ContentManagement::class)->access(request()->user())['opportunities']['create'] ?? null; @@ -75,16 +84,20 @@ final class SectorInvestment 'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null]; } - /** Published guides for an explicit sector; never inferred from proximity. */ + /** Published guides for an explicit sector; never inferred from proximity. + * @return array + */ public function guides(?Sector $sector): array { - if (! $sector) return []; + if (! $sector) { + return []; + } $articles = []; if (app(LegacyContent::class)->available()) { foreach (self::CONTENT[$sector->slug] ?? [] as $slug) { try { $a = app(LegacyContent::class)->article($slug, false); - $articles[] = \Illuminate\Support\Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']); + $articles[] = Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']); } catch (HttpException $e) { if ($e->getStatusCode() !== 404) { throw $e; @@ -92,9 +105,13 @@ final class SectorInvestment } } } + return $articles; } + /** + * @param array $input + */ public function attach(User $actor, string $id, array $input): void { $data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate(); diff --git a/app/Services/SectorOrganization.php b/app/Services/SectorOrganization.php index 37888150..0b87babe 100644 --- a/app/Services/SectorOrganization.php +++ b/app/Services/SectorOrganization.php @@ -1,15 +1,19 @@ + */ public static function slugs(string $slug): array { return match ($slug) { @@ -32,17 +38,27 @@ final class SectorOrganization }; } + /** + * @param Builder $query + * @return Builder + */ public static function constrain(Builder $query, string $slug): Builder { return $query->whereHas('sector', fn ($s) => $s->whereIn('slug', self::slugs($slug))); } + /** + * @return Builder + */ public static function opportunities(Sector $sector): Builder { return self::constrain(Opportunity::published(), $sector->slug); } - public static function catalog() + /** + * @return Collection + */ + public static function catalog(): Collection { return Sector::catalog()->orderBy('sort_order')->get()->each(function ($sector) { $sector->setAttribute('opportunities_count', self::opportunities($sector)->count()); diff --git a/app/Services/SectorReconciliationExecution.php b/app/Services/SectorReconciliationExecution.php index 1ae0cb2c..e0525928 100644 --- a/app/Services/SectorReconciliationExecution.php +++ b/app/Services/SectorReconciliationExecution.php @@ -2,18 +2,22 @@ namespace App\Services; +use App\Models\User; +use App\Support\CheckedFile; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Validator; /** Existing session, permission and MFA control; no actor argument or console entry point. */ final class SectorReconciliationExecution { public const SOURCE = 'sector-reconciliation-authenticated'; + public const OPERATION = 'sector-reconciliation-20261018'; - private function actor(Request $request): \App\Models\User + private function actor(Request $request): User { abort_if(app()->runningInConsole(), 403, 'A execução exige a sessão HTTP autenticada existente.'); $actor = Auth::guard('web')->user(); @@ -21,7 +25,7 @@ final class SectorReconciliationExecution abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403); // Same installation permission and Fortify session MFA used by 140000/150000. app(PortalProfileInstallation::class)->authorize($actor, true); - abort_unless((int)$request->session()->get('portal.mfa_at', 0) <= time(), 403, 'MFA da sessão inválido.'); + abort_unless((int) $request->session()->get('portal.mfa_at', 0) <= time(), 403, 'MFA da sessão inválido.'); return $actor; } @@ -29,45 +33,62 @@ final class SectorReconciliationExecution public function revision(): string { $file = database_path('data/sector-reconciliation-execution.json'); - $manifest = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + $manifest = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR); $files = ['database/data/sector-reconciliation-execution.json' => hash_file('sha256', $file)]; - foreach ($manifest['files'] as $path) $files[$path] = hash_file('sha256', base_path($path)); + foreach ($manifest['files'] as $path) { + $files[$path] = hash_file('sha256', base_path($path)); + } $sql = database_path('data/sector-reconciliation-20261018.sql'); - abort_unless(hash_equals($manifest['sql_sha256'], hash_file('sha256', $sql)), 409, 'SQL diferente da revisão delimitada.'); + abort_unless(hash_equals($manifest['sql_sha256'], CheckedFile::sha256($sql)), 409, 'SQL diferente da revisão delimitada.'); $files['database/data/sector-reconciliation-20261018.sql'] = hash_file('sha256', $sql); - foreach (\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $build) $files['public/build/'.$build->getRelativePathname()] = hash_file('sha256', $build->getPathname()); + foreach (File::allFiles(public_path('build')) as $build) { + $files['public/build/'.$build->getRelativePathname()] = hash_file('sha256', $build->getPathname()); + } ksort($files); return hash('sha256', json_encode($files, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); } + /** + * @return array + */ private function target(): array { return (array) DB::connection()->selectOne('SELECT current_database() AS database, inet_server_port() AS port', [], false); } + /** + * @param array $target + */ private function readiness(string $revision, array $target): void { $p = storage_path('app/private/investmoz-v2/sector-reconciliation/readiness.json'); - $proof = is_file($p) ? json_decode(file_get_contents($p), true, 512, JSON_THROW_ON_ERROR) : []; + $proof = is_file($p) ? json_decode(CheckedFile::read($p), true, 512, JSON_THROW_ON_ERROR) : []; abort_unless(($proof['operation'] ?? '') === self::OPERATION && ($proof['revision'] ?? '') === $revision - && ($proof['target'] ?? []) === $target && !empty($proof['authorization_record']), 409, 'Préflight/autorização da revisão e destino necessários.'); - $isolated = app()->environment('testing') && (int)$target['port'] === 55441 + && ($proof['target'] ?? []) === $target && ! empty($proof['authorization_record']), 409, 'Préflight/autorização da revisão e destino necessários.'); + $isolated = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') && config('database.connections.pgsql.host') === '127.0.0.1' - && !config('database.connections.pgsql.url'); - if (!$isolated) { + && ! config('database.connections.pgsql.url'); + if (! $isolated) { abort_unless(($proof['restore_verified'] ?? false) && ($proof['restored_boot_verified'] ?? false) && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time(), 409, 'Backup/restauro fresco do procedimento existente necessário.'); - foreach (['dump', 'files'] as $kind) abort_unless(is_file($proof[$kind.'_path'] ?? '') - && hash_equals($proof[$kind.'_sha256'] ?? '', hash_file('sha256', $proof[$kind.'_path'])), 409); + foreach (['dump', 'files'] as $kind) { + abort_unless(is_file($proof[$kind.'_path'] ?? '') + && hash_equals($proof[$kind.'_sha256'] ?? '', CheckedFile::sha256($proof[$kind.'_path'])), 409); + } } } + /** + * @return array + */ public function preview(Request $request): array { $actor = $this->actor($request); - abort_if($request->query->count(), 422, 'Ator, revisão ou opções por URL recusados.'); - $revision = $this->revision(); $target = $this->target(); $this->readiness($revision, $target); + abort_if($request->query->count() > 0, 422, 'Ator, revisão ou opções por URL recusados.'); + $revision = $this->revision(); + $target = $this->target(); + $this->readiness($revision, $target); $state = ['operation' => self::OPERATION, 'revision' => $revision, 'target' => $target, 'actor' => ['id' => $actor->id, 'name' => $actor->name], 'migrations' => [], 'ready' => true]; $state['hash'] = hash_hmac('sha256', json_encode([$actor->id, $request->session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key')); @@ -75,6 +96,9 @@ final class SectorReconciliationExecution return $state; } + /** + * @return array + */ public function apply(Request $request): array { $actor = $this->actor($request); @@ -82,7 +106,9 @@ final class SectorReconciliationExecution $data = Validator::make($request->all(), ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate(); $db = DB::connection(); abort_unless($db->transactionLevel() === 0, 409, 'A operação deve possuir a sua transação.'); - $revision = $this->revision(); $target = $this->target(); $this->readiness($revision, $target); + $revision = $this->revision(); + $target = $this->target(); + $this->readiness($revision, $target); $requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); $result = $db->transaction(function () use ($db, $actor, $data, $request, $revision, $target, $requestHash) { @@ -90,8 +116,9 @@ final class SectorReconciliationExecution $old = $db->table('audit_events')->useWritePdo()->where('source_system', self::SOURCE)->where('correlation_id', $data['operation_key'])->first(); if ($old) { $saved = json_decode($old->after_data, true, 512, JSON_THROW_ON_ERROR); - abort_unless((int)$old->actor_user_id === $actor->id && ($saved['revision'] ?? '') === $revision + abort_unless((int) $old->actor_user_id === $actor->id && ($saved['revision'] ?? '') === $revision && hash_equals($saved['request_hash'], $requestHash), 409); + return [...$saved['receipt'], 'replayed' => true]; } $plan = $this->preview($request); @@ -99,13 +126,13 @@ final class SectorReconciliationExecution $before = $db->selectOne("SELECT pg_backend_pid() AS pid, txid_current()::text AS txid, NULLIF(current_setting('investmoz.actor_user_id',true),'') AS actor", [], false); abort_unless($before->actor === null, 409, 'Ligação contém um ator anterior; operação recusada.'); // true means SET LOCAL. Same write connection and transaction execute every statement. - $db->selectOne("SELECT set_config('investmoz.actor_user_id',?,true), set_config('investmoz.operation_key',?,true), set_config('investmoz.authorized_revision',?,true)", [(string)$actor->id, $data['operation_key'], $revision], false); + $db->selectOne("SELECT set_config('investmoz.actor_user_id',?,true), set_config('investmoz.operation_key',?,true), set_config('investmoz.authorized_revision',?,true)", [(string) $actor->id, $data['operation_key'], $revision], false); $oldDataEvent = $db->table('audit_events')->useWritePdo()->where('source_system', self::OPERATION)->exists(); - $db->getPdo()->exec(file_get_contents(database_path('data/sector-reconciliation-20261018.sql'))); + $db->getPdo()->exec(CheckedFile::read(database_path('data/sector-reconciliation-20261018.sql'))); $scope = $db->selectOne("SELECT pg_backend_pid() AS pid, txid_current()::text AS txid, current_setting('investmoz.actor_user_id') AS actor", [], false); - abort_unless($scope->pid === $before->pid && $scope->txid === $before->txid && (int)$scope->actor === $actor->id, 500); + abort_unless($scope->pid === $before->pid && $scope->txid === $before->txid && (int) $scope->actor === $actor->id, 500); $receipt = ['operation' => self::OPERATION, 'operation_key' => $data['operation_key'], 'revision' => $revision, - 'actor_user_id' => $actor->id, 'target' => $target, 'changed' => !$oldDataEvent, 'opportunity_id' => 2, + 'actor_user_id' => $actor->id, 'target' => $target, 'changed' => ! $oldDataEvent, 'opportunity_id' => 2, 'sector_id' => 8, 'replayed' => false, 'connection_pid' => $scope->pid, 'transaction_id' => $scope->txid]; $db->table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'sector-reconciliation', 'action' => 'execute', 'actor_user_id' => $actor->id, 'correlation_id' => $data['operation_key'], 'data_classification' => 'internal', diff --git a/app/Services/SimulationExecution.php b/app/Services/SimulationExecution.php index 0bea6edb..2e964e93 100644 --- a/app/Services/SimulationExecution.php +++ b/app/Services/SimulationExecution.php @@ -23,6 +23,10 @@ final class SimulationExecution return $this->createPayload($payload, $actorId, $organizationId, $token, $key); } + /** + * @param array $snapshot + * @return array{simulation: Simulation, token: string|null, replayed: bool} + */ public function createFiscalQaPreview(array $snapshot, int $actorId, string $key): array { abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); @@ -34,6 +38,10 @@ final class SimulationExecution $actorId, null, null, $key); } + /** + * @param array $input + * @return array{simulation: Simulation, token: string|null, replayed: bool} + */ public function createPublishedFiscal(array $input, ?int $actorId, ?string $organizationId, ?string $token, string $key): array { $payload = ['scenario_kind' => 'fiscal_rule_published', 'fiscal_input' => $input, 'organization_id' => $organizationId]; @@ -42,6 +50,10 @@ final class SimulationExecution fn (): array => app(SimulationPublishedFiscal::class)->admit($input)); } + /** + * @param array $payload + * @return array{simulation: Simulation, token: string|null, replayed: bool} + */ private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key, ?callable $admit = null): array { $tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null; diff --git a/app/Services/SimulationFiscalQaPreview.php b/app/Services/SimulationFiscalQaPreview.php index 4dbb4e51..0b94c344 100644 --- a/app/Services/SimulationFiscalQaPreview.php +++ b/app/Services/SimulationFiscalQaPreview.php @@ -7,11 +7,16 @@ namespace App\Services; use App\Models\InvestmentLegalSource; use App\Models\User; use App\Support\IsolatedQaDatabase; +use App\Support\StructuredData; use Illuminate\Support\Facades\DB; /** Exact operation-level IVA calculation against an explicit QA draft; never a payment/return total. */ final class SimulationFiscalQaPreview { + /** + * @param array $input + * @return array + */ public function create(User $actor, string $ruleId, array $input, string $key): array { abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); @@ -20,7 +25,7 @@ final class SimulationFiscalQaPreview $rule = app(SimulationTaxRuleGovernance::class)->show($actor, $ruleId); abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422, 'Seleccione o rascunho do primeiro lote fiscal.'); - abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422, + abort_if(collect(StructuredData::collectionItems($rule->rule_definition['nodes'] ?? []))->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422, 'Este percurso preserva o valor exacto e não aplica arredondamento automático.'); $source = InvestmentLegalSource::findOrFail($rule->legal_source_id); $version = $rule->attributesToArray(); diff --git a/app/Services/SimulationFiscalSnapshot.php b/app/Services/SimulationFiscalSnapshot.php index 5902e42e..8bef7ca8 100644 --- a/app/Services/SimulationFiscalSnapshot.php +++ b/app/Services/SimulationFiscalSnapshot.php @@ -9,6 +9,14 @@ use Brick\Math\BigRational; /** Shared snapshot representation; eligibility is resolved by each admission adapter. */ final class SimulationFiscalSnapshot { + /** + * @param array|null $evaluation + * @param array $input + * @param array $missing + * @param array $resolution + * @param array $version + * @return array + */ public function build(array $version, array $input, array $resolution, ?array $evaluation, array $missing, bool $published = false): array { $date = $input['reference_date']; diff --git a/app/Services/SimulationFiscalSourceAcceptance.php b/app/Services/SimulationFiscalSourceAcceptance.php index 482cfb8f..a0d764be 100644 --- a/app/Services/SimulationFiscalSourceAcceptance.php +++ b/app/Services/SimulationFiscalSourceAcceptance.php @@ -4,6 +4,9 @@ namespace App\Services; final class SimulationFiscalSourceAcceptance { + /** + * @param array $source + */ public static function eligible(array $source, ?string $hash = null): bool { if (($source['validation_status'] ?? null) === 'validated') { diff --git a/app/Services/SimulationFormDrafts.php b/app/Services/SimulationFormDrafts.php index 29fa33b9..86aa27b2 100644 --- a/app/Services/SimulationFormDrafts.php +++ b/app/Services/SimulationFormDrafts.php @@ -60,7 +60,9 @@ final class SimulationFormDrafts throw new HttpException(409, 'Já existe um rascunho activo. Carregue-o antes de guardar novas alterações.'); } - if ($case) $data['economic_snapshot'] = $this->landContext($data['economic_snapshot'], $case); + if ($case) { + $data['economic_snapshot'] = $this->landContext($data['economic_snapshot'], $case); + } $snapshotHash = hash('sha256', json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); $draft = SimulationFormDraft::query()->create([ 'id' => (string) Str::uuid(), 'owner_user_id' => $actorId, 'anonymous_token_hash' => $tokenHash, @@ -459,15 +461,30 @@ final class SimulationFormDrafts return $query; } + /** + * @param array $economic + * @param array $previous + * @return array + */ private function preserveInactiveLandStatus(SimulationFormDraft $draft, array $economic, array $previous, ?int $actorId): array { - if (! $actorId || ! config('investor-path.enabled') || ! app(VirtualDesk::class)->installed()) return $economic; + if (! $actorId || ! config('investor-path.enabled') || ! app(VirtualDesk::class)->installed()) { + return $economic; + } // Use the real owner-bound dossier even if a request omits its context parameter. $case = DB::table('desk_cases')->where('simulation_id', $draft->id)->where('user_id', $actorId)->first(); - if (! $case) return $economic; + if (! $case) { + return $economic; + } + return $this->landContext($economic, $case, $previous); } + /** + * @param array $economic + * @param array|null $previous + * @return array + */ private function landContext(array $economic, \stdClass $case, ?array $previous = null): array { $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true); @@ -475,10 +492,16 @@ final class SimulationFormDrafts $key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), is_string($key) ? $key : null); $decision = app(InvestorContext::class)->landPreparation($case, $key); if ($decision['need'] !== 'yes' && $previous !== null) { - if (array_key_exists('land_status', $previous)) $economic['land_status'] = $previous['land_status']; - else unset($economic['land_status']); - if (isset($previous['selection_labels']['land_status'])) $economic['selection_labels']['land_status'] = $previous['selection_labels']['land_status']; - else unset($economic['selection_labels']['land_status']); + if (array_key_exists('land_status', $previous)) { + $economic['land_status'] = $previous['land_status']; + } else { + unset($economic['land_status']); + } + if (isset($previous['selection_labels']['land_status'])) { + $economic['selection_labels']['land_status'] = $previous['selection_labels']['land_status']; + } else { + unset($economic['selection_labels']['land_status']); + } } $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true); if (! is_array($snapshot) || ($snapshot['version'] ?? null) !== 1) { @@ -486,7 +509,9 @@ final class SimulationFormDrafts } $snapshot['land_preparation'] = $decision; $snapshot['consumer_opportunity'] = $key; - if ($key) $economic['context_opportunity'] = $key; + if ($key) { + $economic['context_opportunity'] = $key; + } $economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); return $economic; diff --git a/app/Services/SimulationIvaDiagnostics.php b/app/Services/SimulationIvaDiagnostics.php index 096bcc4d..912835f2 100644 --- a/app/Services/SimulationIvaDiagnostics.php +++ b/app/Services/SimulationIvaDiagnostics.php @@ -5,6 +5,11 @@ namespace App\Services; /** Explains a failed selection; never changes answers, eligibility or calculation. */ final class SimulationIvaDiagnostics { + /** + * @param array $context + * @param array $versions + * @return array + */ public function explain(array $versions, string $start, string $end, array $context): array { $diagnostics = []; diff --git a/app/Services/SimulationPublishedFiscal.php b/app/Services/SimulationPublishedFiscal.php index 2ecfa5eb..78e9df8e 100644 --- a/app/Services/SimulationPublishedFiscal.php +++ b/app/Services/SimulationPublishedFiscal.php @@ -6,7 +6,9 @@ namespace App\Services; use App\Models\InvestmentLegalSource; use App\Models\SimulationTaxRuleVersion; +use App\Support\StructuredData; use Brick\Math\BigRational; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -14,6 +16,9 @@ use Illuminate\Support\Str; /** Admission freezes eligibility without granting the public consumer governance write access. */ final class SimulationPublishedFiscal { + /** + * @return array + */ public function availability(): array { $enabled = (bool) config('simulation-fiscal-consumer.enabled'); @@ -24,6 +29,9 @@ final class SimulationPublishedFiscal : 'O cálculo de IVA geral sobre base líquida está indisponível para novas execuções. Os históricos mantêm-se acessíveis.']; } + /** + * @return Builder + */ private function boundRule(): Builder { if (! Str::isUuid((string) config('simulation-fiscal-consumer.rule_id')) || config('simulation-fiscal-consumer.rule_version') < 1) { @@ -36,6 +44,10 @@ final class SimulationPublishedFiscal ->where('scope_key', 'mz.internal-general-net.mzn'); } + /** + * @param array $input + * @return array + */ public function admit(array $input, bool $missingBase = false): array { if (isset($input['monthly_sales'])) { @@ -51,7 +63,7 @@ final class SimulationPublishedFiscal 'A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.'); abort_if($input['reference_date'] < '2026-01-01' || $input['reference_date'] > '2026-12-31', 422, 'Este cálculo abrange apenas exigibilidade em 2026. As entradas foram preservadas.'); - abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422, + abort_if(collect(StructuredData::collectionItems($rule->rule_definition['nodes'] ?? []))->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422, 'A versão seleccionada não é compatível com o cálculo exacto deste consumidor.'); DB::select('select pg_advisory_xact_lock_shared(261022, hashtext(?))', [(string) $rule->legal_source_id]); $source = InvestmentLegalSource::whereKey($rule->legal_source_id)->firstOrFail(); @@ -87,6 +99,10 @@ final class SimulationPublishedFiscal return app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing, true); } + /** + * @param array $input + * @return array + */ private function admitMonthly(array $input): array { $rows = []; @@ -95,7 +111,7 @@ final class SimulationPublishedFiscal $complete = true; $previous = null; foreach ($input['monthly_sales'] as $sale) { - abort_unless($previous === null || $sale['date'] === date('Y-m-d', strtotime($previous.' +1 month')), 422, 'A projecção deve conter meses consecutivos, sem duplicações.'); + abort_unless($previous === null || $sale['date'] === CarbonImmutable::parse($previous)->addMonth()->format('Y-m-d'), 422, 'A projecção deve conter meses consecutivos, sem duplicações.'); $previous = $sale['date']; $known = $sale['amount'] !== null && $sale['amount'] !== ''; $net = $input['sales_vat_basis'] === 'net'; @@ -103,7 +119,7 @@ final class SimulationPublishedFiscal 'currency' => $input['currency'], 'reference_date' => $sale['date'], 'operation' => 'internal_general_net', 'scope_declared' => $input['scope_declared']], ! $known || ! $net); $snapshot ??= $month; - $rate = collect($month['fiscal_provenance']['resolution'])->firstWhere('parameter_code', 'IVA_GENERAL_RATE'); + $rate = collect(StructuredData::collectionItems($month['fiscal_provenance']['resolution']))->firstWhere('parameter_code', 'IVA_GENERAL_RATE'); $vat = $month['result']['value']; $status = ! $known ? 'missing_sales' : (! $net ? 'unsupported_basis' : ($vat === null ? 'indeterminate' : 'calculated')); $rows[] = ['date' => $sale['date'], 'sales_declared' => $sale['amount'], diff --git a/app/Services/SimulationTaxParameterResolver.php b/app/Services/SimulationTaxParameterResolver.php index 7fcaf329..201b8955 100644 --- a/app/Services/SimulationTaxParameterResolver.php +++ b/app/Services/SimulationTaxParameterResolver.php @@ -138,7 +138,11 @@ final class SimulationTaxParameterResolver return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'published'); } - /** Explicit draft preview. Never used by the published-parameter API. */ + /** Explicit draft preview. Never used by the published-parameter API. + * @param array $context + * @param array $versions + * @return list> + */ public function resolveDraftForQa(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array { abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404); @@ -146,6 +150,11 @@ final class SimulationTaxParameterResolver return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'draft'); } + /** + * @param array $context + * @param array $versions + * @return list> + */ private function resolveWithStatus(array $versions, string $component, string $periodStart, string $periodEnd, array $context, string $requiredStatus): array { $start = $this->date($periodStart); diff --git a/app/Services/VerifiedDocumentBinary.php b/app/Services/VerifiedDocumentBinary.php index 00cdcb84..b8e3d6d4 100644 --- a/app/Services/VerifiedDocumentBinary.php +++ b/app/Services/VerifiedDocumentBinary.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\Opportunity; +use App\Models\User; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Response; use Illuminate\Support\Facades\Crypt; @@ -62,14 +64,16 @@ class VerifiedDocumentBinary $links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload'])); } $privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true); - if (! $privateOrigin) $links = $links->reject(fn ($link) => isset(json_decode($link->payload, true)['library_source'])); + if (! $privateOrigin) { + $links = $links->reject(fn ($link) => isset(json_decode($link->payload, true)['library_source'])); + } if ($links->isEmpty() && ! $privateOrigin) { return; // Other consumers retain their own publication/authorization rules. } $actor = request()->user(); - abort_unless($actor instanceof \App\Models\User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.'); + abort_unless($actor instanceof User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.'); foreach ($links as $link) { - $opportunity = \App\Models\Opportunity::find($link->opportunity_id); + $opportunity = Opportunity::whereKey($link->opportunity_id)->first(); if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived' && app(PortalProfileAccess::class)->canView($actor, $opportunity)) { return; diff --git a/app/Services/VirtualDesk.php b/app/Services/VirtualDesk.php index 1626d6f8..f05cb51f 100644 --- a/app/Services/VirtualDesk.php +++ b/app/Services/VirtualDesk.php @@ -197,9 +197,13 @@ class VirtualDesk $this->event($case, $actor, 'request_prefill_prepared', ['draft_id' => $draft->id, 'draft_version' => $draft->version_no, 'snapshot_sha256' => $draft->snapshot_sha256, 'fields' => ['title', 'description']]); } $context = app(InvestorContext::class)->state($case); - if (count($context['opportunity_choices'] ?? []) > 1 && !$opportunity) $payload['description'] = ''; + if (count($context['opportunity_choices'] ?? []) > 1 && ! $opportunity) { + $payload['description'] = ''; + } $mapped = app(InvestorContext::class)->prefill($context, $opportunity)['desk']; - if ($opportunity) $payload['context_opportunity'] = $opportunity; + if ($opportunity) { + $payload['context_opportunity'] = $opportunity; + } $payload = app(InvestmentConsumerScope::class)->bind($actor, $caseId, $payload); $serviceFields = json_decode($service->definition, true)['fields']; foreach (['territory_id' => 'territory_id', 'location_source' => 'location_source', 'title' => 'title', 'description' => 'description', 'sector' => 'sector', 'province_id' => 'province_id', 'district_id' => 'district_id', 'admin_post_id' => 'admin_post_id', 'locality_id' => 'locality_id'] as $field => $source) { @@ -237,7 +241,9 @@ class VirtualDesk if (count($contextChoices) > 1) { $snapshot = json_decode($economic['context_applicability_snapshot'] ?? '{}', true); $draftChoices = $snapshot['opportunity_choices'] ?? []; - if (count($draftChoices) !== 1 || ($draftChoices[0]['opportunity']['key'] ?? null) !== ($payload['context_opportunity'] ?? null)) return null; + if (count($draftChoices) !== 1 || ($draftChoices[0]['opportunity']['key'] ?? null) !== ($payload['context_opportunity'] ?? null)) { + return null; + } } $description = (string) ($economic['project_summary'] ?? ''); if (trim($description) === '') { diff --git a/app/Support/CheckedFile.php b/app/Support/CheckedFile.php new file mode 100644 index 00000000..6fd9302e --- /dev/null +++ b/app/Support/CheckedFile.php @@ -0,0 +1,48 @@ + */ + public static function split(string $pattern, string $value): array + { + $parts = preg_split($pattern, $value); + if ($parts === false) { + throw new RuntimeException('Text could not be split; original content has been preserved.'); + } + + return $parts; + } +} diff --git a/app/Support/StructuredData.php b/app/Support/StructuredData.php new file mode 100644 index 00000000..d67f721b --- /dev/null +++ b/app/Support/StructuredData.php @@ -0,0 +1,30 @@ + */ + public static function collectionItems(mixed $value): array + { + if ($value instanceof Collection) { + return $value->all(); + } + if ($value instanceof Traversable) { + return iterator_to_array($value); + } + if ($value instanceof Arrayable) { + return $value->toArray(); + } + if (! is_array($value)) { + throw new UnexpectedValueException('Structured data must contain an array; original data has been preserved.'); + } + + return $value; + } +} diff --git a/config/homologation_editorial.php b/config/homologation_editorial.php index e5cc2470..da0b60fc 100644 --- a/config/homologation_editorial.php +++ b/config/homologation_editorial.php @@ -1,4 +1,5 @@ $table->text('editorial_draft')->nullable()); + if (! config('investment-zone-profile.authenticated_installation') && ! IsolatedQaDatabase::allowsSchemaChanges()) { + throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); + } + Schema::table('investment_zones', fn (Blueprint $table) => $table->text('editorial_draft')->nullable()); } + public function down(): void { if (DB::table('investment_zones')->whereNotNull('editorial_draft')->exists() - || DB::table('investment_zone_revisions')->where('action','profile_prepared')->exists()) { + || DB::table('investment_zone_revisions')->where('action', 'profile_prepared')->exists()) { throw new RuntimeException('Reversão parcial compatível: preservar coluna e histórico após utilização da ficha.'); } - Schema::table('investment_zones',fn(Blueprint $table)=>$table->dropColumn('editorial_draft')); + Schema::table('investment_zones', fn (Blueprint $table) => $table->dropColumn('editorial_draft')); } }; diff --git a/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php b/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php index 91159479..9ef74b16 100644 --- a/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php +++ b/database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php @@ -1,22 +1,29 @@ where('migration','2026_10_16_130000_add_investment_zone_editorial_draft')->exists()){throw new RuntimeException('Instale/verifique primeiro a base 130000.');} - Schema::table('investment_zones',fn(Blueprint $table)=>$table->jsonb('operational_details')->nullable()); + if (! config('investment-zone-details.authenticated_installation') && ! IsolatedQaDatabase::allowsSchemaChanges()) { + throw new RuntimeException('Use a instalação autenticada nominal L1.'); + } + if (! Schema::hasColumn('investment_zones', 'editorial_draft') || ! DB::table('migrations')->where('migration', '2026_10_16_130000_add_investment_zone_editorial_draft')->exists()) { + throw new RuntimeException('Instale/verifique primeiro a base 130000.'); + } + Schema::table('investment_zones', fn (Blueprint $table) => $table->jsonb('operational_details')->nullable()); } + public function down(): void { - if(DB::table('investment_zones')->whereNotNull('operational_details')->exists() || DB::table('investment_zone_revisions')->whereIn('action',['profile_prepared','published'])->exists()){ + if (DB::table('investment_zones')->whereNotNull('operational_details')->exists() || DB::table('investment_zone_revisions')->whereIn('action', ['profile_prepared', 'published'])->exists()) { throw new RuntimeException('Reversão compatível: preserve os detalhes, preparação privada e versões públicas após utilização.'); } - Schema::table('investment_zones',fn(Blueprint $table)=>$table->dropColumn('operational_details')); + Schema::table('investment_zones', fn (Blueprint $table) => $table->dropColumn('operational_details')); } }; diff --git a/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php b/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php index f58cb54a..ac0cad96 100644 --- a/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php +++ b/database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php @@ -1,5 +1,6 @@ $t->jsonb('editorial_content')->nullable()); } diff --git a/database/migrations/2026_10_28_180000_create_proactive_supply_observations.php b/database/migrations/2026_10_28_180000_create_proactive_supply_observations.php index a31cefd0..c9270ce2 100644 --- a/database/migrations/2026_10_28_180000_create_proactive_supply_observations.php +++ b/database/migrations/2026_10_28_180000_create_proactive_supply_observations.php @@ -9,7 +9,7 @@ return new class extends Migration public function up(): void { Schema::create('proactive_supply_sources', function ($t) { - $t->char('sha256',64)->primary(); + $t->char('sha256', 64)->primary(); $t->text('name'); $t->text('period'); $t->text('limitations'); @@ -17,23 +17,25 @@ return new class extends Migration $t->timestampTz('imported_at'); }); Schema::create('proactive_supply_observations', function ($t) { - $t->char('id',64)->primary(); - $t->char('source_hash',64); + $t->char('id', 64)->primary(); + $t->char('source_hash', 64); $t->foreign('source_hash')->references('sha256')->on('proactive_supply_sources')->restrictOnDelete(); $t->text('sheet'); $t->integer('row_number'); - $t->char('entity_key',64)->index(); - foreach (['name','province','district','cae','activity','sector','source_date'] as $field) $t->text($field); - $t->char('fingerprint',64)->index(); - $t->char('duplicate_of',64)->nullable(); + $t->char('entity_key', 64)->index(); + foreach (['name', 'province', 'district', 'cae', 'activity', 'sector', 'source_date'] as $field) { + $t->text($field); + } + $t->char('fingerprint', 64)->index(); + $t->char('duplicate_of', 64)->nullable(); $t->boolean('identity_conflict')->default(false); $t->uuid('territory_id')->nullable()->index(); $t->text('territory_resolution'); - $t->index(['province','district']); - $t->unique(['source_hash','sheet','row_number']); + $t->index(['province', 'district']); + $t->unique(['source_hash', 'sheet', 'row_number']); }); // Technical defence of the private-analysis scope, not a new business role. - foreach (['proactive_supply_sources','proactive_supply_observations'] as $table) { + foreach (['proactive_supply_sources', 'proactive_supply_observations'] as $table) { DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY"); DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY"); DB::statement("CREATE POLICY {$table}_private_read ON {$table} FOR SELECT USING (current_setting('app.proactive_supply_read',true)='internal' AND NULLIF(current_setting('app.actor_user_id',true),'')::bigint IS NOT NULL)"); @@ -42,7 +44,9 @@ return new class extends Migration public function down(): void { - if (DB::table('proactive_supply_sources')->exists()) throw new RuntimeException('Preserve imported source history before rollback.'); + if (DB::table('proactive_supply_sources')->exists()) { + throw new RuntimeException('Preserve imported source history before rollback.'); + } Schema::dropIfExists('proactive_supply_observations'); Schema::dropIfExists('proactive_supply_sources'); } diff --git a/database/qa/iva-general-net-v1.json b/database/qa/iva-general-net-v1.json index f800568c..788ed4f9 100644 --- a/database/qa/iva-general-net-v1.json +++ b/database/qa/iva-general-net-v1.json @@ -1,38 +1,41 @@ { - "rule_code": "IVA-GERAL-BASE-LIQUIDA", - "rule_version": 1, - "component": "IVA", - "scope_key": "mz.internal-general-net.mzn", - "valid_from": "2023-01-01", - "valid_until": null, - "source_sha256": "ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53", - "rule_definition": { - "language": "investmoz.tax.v1", - "nodes": { - "base": {"op": "input", "key": "base_tributavel"}, - "rate": {"op": "parameter", "key": "iva_general_rate"}, - "hundred": {"op": "literal", "value": "100"}, - "product": {"op": "multiply", "args": ["base", "rate"]}, - "intermediate": {"op": "divide", "args": ["product", "hundred"]} + "rule_code": "IVA-GERAL-BASE-LIQUIDA", + "rule_version": 1, + "component": "IVA", + "scope_key": "mz.internal-general-net.mzn", + "valid_from": "2023-01-01", + "valid_until": null, + "source_sha256": "ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53", + "rule_definition": { + "language": "investmoz.tax.v1", + "nodes": { + "base": { "op": "input", "key": "base_tributavel" }, + "rate": { "op": "parameter", "key": "iva_general_rate" }, + "hundred": { "op": "literal", "value": "100" }, + "product": { "op": "multiply", "args": ["base", "rate"] }, + "intermediate": { "op": "divide", "args": ["product", "hundred"] } + }, + "result": "intermediate" }, - "result": "intermediate" - }, - "parameters": { - "IVA_GENERAL_RATE": { - "value": "16", - "unit": "percent", - "conditions": {"operation": "internal_general_net", "currency": "MZN"} + "parameters": { + "IVA_GENERAL_RATE": { + "value": "16", + "unit": "percent", + "conditions": { + "operation": "internal_general_net", + "currency": "MZN" + } + } + }, + "rounding_policy": { + "status": "not_applied", + "precision": "exact_decimal", + "method": "none", + "ties": "not_applicable_without_rounding", + "aggregation": "single_declared_taxable_base", + "purpose": "operation_tax_not_payment", + "evidence": "Orientação expressa Tempo: calcular este lote IVA sem arredondamento automático; ficha v1.2", + "coverage_start": "2026-01-01", + "coverage_end": "2026-12-31" } - }, - "rounding_policy": { - "status": "not_applied", - "precision": "exact_decimal", - "method": "none", - "ties": "not_applicable_without_rounding", - "aggregation": "single_declared_taxable_base", - "purpose": "operation_tax_not_payment", - "evidence": "Orientação expressa Tempo: calcular este lote IVA sem arredondamento automático; ficha v1.2", - "coverage_start": "2026-01-01", - "coverage_end": "2026-12-31" - } } diff --git a/deploy/homologacao/delivery.example.json b/deploy/homologacao/delivery.example.json index fd64ea1b..a3c2e51d 100644 --- a/deploy/homologacao/delivery.example.json +++ b/deploy/homologacao/delivery.example.json @@ -1,17 +1,17 @@ { - "repository": "nala/Investidor-V2", - "branch": "main", - "target_url": "https://teste-investidor.tempo.co.mz", - "legacy_root": "/var/www/html/teste-investidor.tempo.co.mz", - "state_root": "/var/lib/investmoz-deploy", - "release_root": "/var/www/investmoz-homologacao/releases", - "current_link": "/var/www/html/teste-investidor.tempo.co.mz/current", - "shared_assets": "/var/www/investmoz-homologacao/shared/build-assets", - "php": "/usr/bin/php8.4", - "schema_contract": "/etc/investmoz-deploy/schema-contract.json", - "ci_environment": "/etc/investmoz-deploy/ci-environment.json", - "postgis_image": "postgis/postgis:17-3.5", - "test_database": "investmoz_ci_test", - "qa_port": 55441, - "retention": "No automatic deletion of releases or data" + "repository": "nala/Investidor-V2", + "branch": "main", + "target_url": "https://teste-investidor.tempo.co.mz", + "legacy_root": "/var/www/html/teste-investidor.tempo.co.mz", + "state_root": "/var/lib/investmoz-deploy", + "release_root": "/var/www/investmoz-homologacao/releases", + "current_link": "/var/www/html/teste-investidor.tempo.co.mz/current", + "shared_assets": "/var/www/investmoz-homologacao/shared/build-assets", + "php": "/usr/bin/php8.4", + "schema_contract": "/etc/investmoz-deploy/schema-contract.json", + "ci_environment": "/etc/investmoz-deploy/ci-environment.json", + "postgis_image": "postgis/postgis:17-3.5", + "test_database": "investmoz_ci_test", + "qa_port": 55441, + "retention": "No automatic deletion of releases or data" } diff --git a/deploy/homologacao/reconciled-baseline.json b/deploy/homologacao/reconciled-baseline.json index 63aa56f1..adafce45 100644 --- a/deploy/homologacao/reconciled-baseline.json +++ b/deploy/homologacao/reconciled-baseline.json @@ -1,446 +1,446 @@ { - "base_commit": "507387502c6ef887433e8e9b5cf20ea39d4f17d4", - "installed_revision": "423bd512d8c5", - "approved_runtime_files_adopted": [ - { - "path": "app/Http/Controllers/Api/V1/InterestRequestController.php", - "remote_sha256": "50b65d0dd576d1c0e4357fe5a192744900eb77b2e5737191ecc553428e6ccf14" - }, - { - "path": "app/Http/Controllers/DocumentMalwareController.php", - "remote_sha256": "b19b028ae53fcb8ed648745c9de0121e128e489a9cdd1c3eb8a4210c297416d4" - }, - { - "path": "app/Http/Controllers/EditorialArticleController.php", - "remote_sha256": "41e34a865890c81191041ec1c34b0abc1ab052c99ae3b96edc930f841c5a33c5" - }, - { - "path": "app/Http/Controllers/ExistingBusinessController.php", - "remote_sha256": "e73ac0fbe1c4ae3939c27e5ca1b6e76642611a5c117c7473f21c284235fe75da" - }, - { - "path": "app/Http/Controllers/InvestmentSubmissionController.php", - "remote_sha256": "257837ff8100d5fbc3a432ec336aacc4f37af01d7044e0c44d37de2d4ec44b4c" - }, - { - "path": "app/Http/Controllers/InvestmentZoneController.php", - "remote_sha256": "32ed3b3f662b7fcfb8fce09c7626680f3409b0b3bd6acd662088e0adc22cf53b" - }, - { - "path": "app/Http/Controllers/OpportunityResourceController.php", - "remote_sha256": "da265c473a783423321fa75f1b02eb62f311b80e4cb317298d5ac4883630e2c6" - }, - { - "path": "app/Http/Controllers/PortalManagementController.php", - "remote_sha256": "c49ba1d490f88e65daae21ad0038608a71aef547d7b77acd096cdc8a4d695c0d" - }, - { - "path": "app/Http/Controllers/PortalServicesController.php", - "remote_sha256": "8e7595a42945876f9d2787a8fafaa7cec43c6dfafdc79a9aee73fa8aa533c359" - }, - { - "path": "app/Http/Controllers/VirtualDeskController.php", - "remote_sha256": "e4620b41820945512283f7f0cdd46f70916b9935288ec91a00e3d1c388af950d" - }, - { - "path": "app/Http/Middleware/EnsureDocumentManagement.php", - "remote_sha256": "7e373bffc2517366066a32889b36f277f5cea242c0f3277ef291e2c98fb8520e" - }, - { - "path": "app/Http/Middleware/EnsureHomologationAccess.php", - "remote_sha256": "259f467b08d4ad5a1605a40c5f3ccb9e41b2a879643df1f8e4814f5d0c82a8b4" - }, - { - "path": "app/Http/Middleware/EnsureScopedEditorial.php", - "remote_sha256": "62c4c16506f5e75ef76e3fa987d6b5ba42d5df953604f1aeecb89c87c7c5c7ca" - }, - { - "path": "app/Http/Middleware/EnsureScopedPortalAdministration.php", - "remote_sha256": "3842b05ddd4433b5a26c0dea6b97542b2b3846f46f0393c5515872ded43f0f61" - }, - { - "path": "app/Http/Middleware/HandleInertiaRequests.php", - "remote_sha256": "01083ba8e8aec0f0a346e6f86739155122b52fd44f736c066683f0c04c9bdc73" - }, - { - "path": "app/Http/Middleware/VerifiedOrSimpleParticipation.php", - "remote_sha256": "301e0ce37a92e0985bfb5b914aa13a32a0081f8ead88842ff4911c043553780a" - }, - { - "path": "app/Http/Requests/StoreInterestRequestRequest.php", - "remote_sha256": "a064fc0c71139b6ced9d209a490c1f339733ae9ad513c0128520b2942384eeac" - }, - { - "path": "app/Policies/ExistingBusinessPolicy.php", - "remote_sha256": "69c75db509d876d6125da7d4254ac3be6e23e0ee942dea460bd86fc73aa02def" - }, - { - "path": "app/Services/BusinessFormSchema.php", - "remote_sha256": "bf7509574c8c66da49b2f2d488a4dbc9896a15daebd78404fa4832c0322fe6f7" - }, - { - "path": "app/Services/DeclaredOriginStatistics.php", - "remote_sha256": "b5a20759938e2d5652018d6ec0b1b1d8dda6cdddbd4f7767fd5dbfa0da0e2f4c" - }, - { - "path": "app/Services/DocumentManagementAccess.php", - "remote_sha256": "6b2a0e8b75cb99f8baa9f40b4eda7cd5171348dd2126833ca3399e252baed4d0" - }, - { - "path": "app/Services/EconomicReferenceInstallation.php", - "remote_sha256": "a22050130c8cefdb18f2dafe03a4158f3e1596c5158cb9fd262abbf63c5ab144" - }, - { - "path": "app/Services/ExistingBusinessIntake.php", - "remote_sha256": "30d2325030e4024da1a15bc08085db8037fa42d09b1ece9c071386f346597937" - }, - { - "path": "app/Services/ExistingBusinessProfileCategory.php", - "remote_sha256": "46c045c19bff743a507a2efe0fc333454e82a9a48c2b355cf56834b638951737" - }, - { - "path": "app/Services/InterestWorkflow.php", - "remote_sha256": "d29080a7757c07505b418d4148cdb4abaaeb213a7b004bc21c52a135af99dca8" - }, - { - "path": "app/Services/InvestmentZoneOperationalDetailsInstallation.php", - "remote_sha256": "2b9f47cf7e1ddf14069440691485733f29cbe5fc0fb5b0091d15e96f5b8391d9" - }, - { - "path": "app/Services/InvestmentZoneProfileInstallation.php", - "remote_sha256": "026d6e1752fc8c4c80c14d5ce87bf5ea53d585a917a0cd3cd2c9d522247bb7db" - }, - { - "path": "app/Services/InvestorContext.php", - "remote_sha256": "1e7d9c7096b72a23df17611247c315ae6c8e76472c87638409be987c3cbba273" - }, - { - "path": "app/Services/LegacyContent.php", - "remote_sha256": "7138d214165859ad9ae4f4be81de9ab39978257264025a357b9b84aa90e661bc" - }, - { - "path": "app/Services/Malware/ClamDScanner.php", - "remote_sha256": "b708454c18793cbdfa761010905a0e423adfbaddeaf49ac342d88b1dc2e5d3e0" - }, - { - "path": "app/Services/OpportunityCanonicalContract.php", - "remote_sha256": "75ffb95495b630193710830f5b9913681f544baacebeac7e7be5d7f7815e59cd" - }, - { - "path": "app/Services/OpportunityCategoryFields.php", - "remote_sha256": "d7d294c72b7c416bd91726e7d7e2a045968e85d62ddaf57137da921e0340f25c" - }, - { - "path": "app/Services/OpportunityCompleteness.php", - "remote_sha256": "1497712c221332e5f879205d94470c5cbe657d543565a99f08eee4543da0ddfb" - }, - { - "path": "app/Services/OpportunityInitiativeApplicability.php", - "remote_sha256": "7efeae3c445bc3e7feb3275bd66a54a0b1370931a1331ac5ef6d7bfa58b4cdb5" - }, - { - "path": "app/Services/OpportunityLibrarySupport.php", - "remote_sha256": "2dea4f3efc76b16c4cccb97ffcfbeccbe4b3151a9a871fe8e2c175f7874fec11" - }, - { - "path": "app/Services/OpportunityResources.php", - "remote_sha256": "cf3baa0e5592f0b039189877faca514c62db65ae2e2bd634b5380c74f56ab412" - }, - { - "path": "app/Services/OpportunityTerritorialPresentation.php", - "remote_sha256": "81d91ffe07531c43e057c81887bfa2891e50c38ff7cda9414a26af67592a2b78" - }, - { - "path": "app/Services/PortalProfileInstallation.php", - "remote_sha256": "4ab58a07c93e743327c592434dc1931057baf1ba81f488f4b51a28022beec4ac" - }, - { - "path": "app/Services/ScopedPortalAdministration.php", - "remote_sha256": "013efc04ecfddaea7473f50c0d0c31f56b5d958bb1594ed913d3d3855b9b0372" - }, - { - "path": "app/Services/VerifiedDocumentBinary.php", - "remote_sha256": "3a602b504bcb26edd3c9182dcab83fef2fd25166e61a840d1503b7a4d98f0728" - }, - { - "path": "app/Services/VirtualDesk.php", - "remote_sha256": "dc958b808d91b353d2c8e7d1244f594c54fc5eb4924e64654818f1cbd1aebda9" - }, - { - "path": "bootstrap/app.php", - "remote_sha256": "b6e04e06d1d7d17f43ca81370ced7d470d5b9274957680110203f20e44ffacc1" - }, - { - "path": "config/homologation_editorial.php", - "remote_sha256": "451a24a9a93fcdc2fd2aec98fb5b8689e13de1239c9b1671906ec58eb1b4e09e" - }, - { - "path": "config/legacy_content_aliases.php", - "remote_sha256": "c8bfbead3a0908c84f7e1d595154d6d75ecebd24737651dad396e8bd0c900c50" - }, - { - "path": "config/portal.php", - "remote_sha256": "6f26c86f4599a93eb1b80bb6fa0657b72fdbc03935d2c262080858fd79287b00" - }, - { - "path": "config/portal_profiles.php", - "remote_sha256": "2e9e3d40277980c9fc139a51eb33212fe3df71a74397c353fe355a5642f99a07" - }, - { - "path": "config/services.php", - "remote_sha256": "98f2875d58ab9283018b5c8e3ba24000f2d2b8be4d67ff107ff33ceac578be7c" - }, - { - "path": "database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php", - "remote_sha256": "ff9b56f7c3d156bedfe0050953c2ea6c10811fd859524a8fb994e176527846f0" - }, - { - "path": "database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php", - "remote_sha256": "3d3e7f00df749475a67c4d098a42cbbc81f9015afc8b74e6be58c86dd1f2abfb" - }, - { - "path": "database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php", - "remote_sha256": "e12017e6a4d1a960057cf2d1df9dbd69faa867bf6a2d08a50dd3089a6a967100" - }, - { - "path": "database/migrations/2026_10_18_160000_add_portal_editorial_content.php", - "remote_sha256": "b3aa3d2510cd17975dbb4ff5f49d741af9f67601ce7e181f6173cfa2368d6334" - }, - { - "path": "resources/js/app.tsx", - "remote_sha256": "858eabfe3f5bf45a2e5a11c08e123fdd8c3243c6648c0d54def6966a0a683f88" - }, - { - "path": "resources/js/components/app-logo.tsx", - "remote_sha256": "e8e95de1d180014edc8fce2a36851727e5721319a9e9dffa90c82734f7b5939c" - }, - { - "path": "resources/js/components/context-documents.tsx", - "remote_sha256": "516871523445ea1efd2415f73c63e1ae74be2da76ceb620708bdf39fe0605999" - }, - { - "path": "resources/js/components/country-select.tsx", - "remote_sha256": "291676dc034a5f3d4d6498f1b80aa9de01b2b256f092fc855177a232b29ec714" - }, - { - "path": "resources/js/components/investment-map-panel.tsx", - "remote_sha256": "7cee11792343dd3ac94d4aeb782e6b23a29cf826b56209dcc5b4291d57b738c5" - }, - { - "path": "resources/js/components/investment-territory.tsx", - "remote_sha256": "38b2945a59601c5a1cea33827f57f105fc521ed3679223d95d5aa76cf6e13855" - }, - { - "path": "resources/js/components/opportunity-canonical-fields.tsx", - "remote_sha256": "f3c763063eee9aeb2da354c54e84c86e3c54f46dfecce4665ee0e8c6c6970d05" - }, - { - "path": "resources/js/components/opportunity-classification.tsx", - "remote_sha256": "06bdcf091f51a7eb44f75790f7d40462172571d1f1219f12620ee84a64fd668e" - }, - { - "path": "resources/js/components/opportunity-library-reuse.tsx", - "remote_sha256": "e95e3358a0016edd00596b09eeb80f5c2f0d149bdb456d8bc2c812cfb32e62f7" - }, - { - "path": "resources/js/components/partnership-search-results.tsx", - "remote_sha256": "efc037858a9a01d643894b308af8145959a7111b2667e393392dae53a3e5ba88" - }, - { - "path": "resources/js/components/password-input.tsx", - "remote_sha256": "f13f872ca86ebfd48fe475efdea32d5ec667414a4bba4529efe19a3bf81b2c16" - }, - { - "path": "resources/js/components/portal-grouped-menu.tsx", - "remote_sha256": "0c1de9b30772632afadcef6871edd6938cdab8f838983fd00cbd473d135432e7" - }, - { - "path": "resources/js/components/portal-product-identity.tsx", - "remote_sha256": "a0d5e22d06bd9dd12dce152425645f0247c7cb88767c23d5f0367e4f91c1e0cc" - }, - { - "path": "resources/js/components/search-feedback.tsx", - "remote_sha256": "bbe5b489b55b5853de553f68bdc6d38708247ca7a0649dda8a26e60148a8905d" - }, - { - "path": "resources/js/components/search-state.tsx", - "remote_sha256": "70d705f327d561c822a679e0739ce02470fbe7a18e4c8b44c9305f7619b496d6" - }, - { - "path": "resources/js/components/structure-geography.tsx", - "remote_sha256": "62f7570cf0b9149f006721ea59d694d66038530702d368444abc3589128619d4" - }, - { - "path": "resources/js/components/tempo-footer.tsx", - "remote_sha256": "14bbdc61d82cf392b43f7bf35e326cec87ddb40fbcce22d9aab9da872f746bc6" - }, - { - "path": "resources/js/hooks/use-interest-draft.ts", - "remote_sha256": "787b3295f3bee1282b4cacfefc1e7650feea54738d35f542d879a4dc13c9d475" - }, - { - "path": "resources/js/layouts/auth/auth-simple-layout.tsx", - "remote_sha256": "6c2b046bd02e7e02017a536873add53b02df79ef17798476b8021c401546462b" - }, - { - "path": "resources/js/layouts/portal-navigation.tsx", - "remote_sha256": "08876167e7de425470d6b97133441221437081b2065b2f1e6ccc9efce40a0b45" - }, - { - "path": "resources/js/lib/investor-context.ts", - "remote_sha256": "19e2e5e6ef85059c29354a0f2a14d26012fdcc9868b84144b18df5fc41a667f9" - }, - { - "path": "resources/js/lib/participation-identity.ts", - "remote_sha256": "e4446f51529d787e83beeb044eede9a3e5f3894cf7f9c51d97542c403f563b6c" - }, - { - "path": "resources/js/lib/portal-api.ts", - "remote_sha256": "2ad0ad502e7805ae834e2368982f17b90cd9e32e4c0de112d14c1f3ef9268ca8" - }, - { - "path": "resources/js/lib/portal-navigation.ts", - "remote_sha256": "17e541f5f0add27bf4af1dc6dae0ce7cdeab96de0162f1b3663cbf4dea2d8c85" - }, - { - "path": "resources/js/lib/project-dossier-document.ts", - "remote_sha256": "28e2ac9929038f9fdbc2fe5bf8dde46e4ff477db29964dc7921381bfbf00cc65" - }, - { - "path": "resources/js/lib/project-dossier-report.ts", - "remote_sha256": "f4a428cd337912df60ec3029e95c6fe9beaf2d3f81cca903690c626828719f22" - }, - { - "path": "resources/js/pages/PortalInvestidor.tsx", - "remote_sha256": "18885a653fe13ea9040a998fb721a9e21a77fc3ead359378fac7b7a740eb9c5d" - }, - { - "path": "resources/js/pages/auth/login.tsx", - "remote_sha256": "3f9231ba2cb69216c61215c701e8929ff9b90fb164f9a179f42d00b7c30c3aa7" - }, - { - "path": "resources/js/pages/auth/register.tsx", - "remote_sha256": "6ea92f1ec50058e39af8901b78256f4c99fcfbefd0f9ebb299acdff05c99d50a" - }, - { - "path": "resources/js/pages/desk/request.tsx", - "remote_sha256": "7b3b8bec1a66a0403e6cdf9a11cf83d59b6a529f5f43b6a5871ba76c1bc7935d" - }, - { - "path": "resources/js/pages/investment/business-interest.tsx", - "remote_sha256": "0cfbde1ba8e4cd164bd2a904c9f5560e4603609c71854b3fdb6db91561b883c4" - }, - { - "path": "resources/js/pages/investment/business-wizard.tsx", - "remote_sha256": "03ce4217bcb34583ceeeab454e40b1ee8aa73ba361d24e833e3c16e5fa165ec4" - }, - { - "path": "resources/js/pages/investment/business.tsx", - "remote_sha256": "3682717821510a19f7e02c14179d72144f50ee822d96ebd011e48d37d6ab2055" - }, - { - "path": "resources/js/pages/investment/catalogue.tsx", - "remote_sha256": "7ac06e80303992fae94b6ba2313ede5cff13da974815460cf59100b07b1ce338" - }, - { - "path": "resources/js/pages/investment/context.tsx", - "remote_sha256": "50b86276a3ac238b678f06dfb536b61ca3711a495453debae2e795082cdc0053" - }, - { - "path": "resources/js/pages/investment/detail.tsx", - "remote_sha256": "743de540cd1caeda5f3afb2b2dc78c088ea56ebe527a5466a3d039b430548688" - }, - { - "path": "resources/js/pages/investment/submission.tsx", - "remote_sha256": "71f03043fd16a806632fa7c0bf503998f0dff99fdd1bbfcaa703a032a2fa6558" - }, - { - "path": "resources/js/pages/investment/zones.tsx", - "remote_sha256": "a1f0b395f027a05ecef134e1bd0c03a20a23b089c7d8c5aa45403eb1c7920a92" - }, - { - "path": "resources/js/pages/investor-path/context-start.tsx", - "remote_sha256": "36d95c80a64a09ee40bd0fec8f6c0f623a47e576667fe08fadf2e3dc8969691e" - }, - { - "path": "resources/js/pages/investor-path/context.tsx", - "remote_sha256": "823da4d6092f553fef7aa801fb1c7d809b53b2c96571aaaeffece67a92af454a" - }, - { - "path": "resources/js/pages/investor-path/show.tsx", - "remote_sha256": "5d56de96fe41ad5b8782a4f1c472c110cbb3e93493bc92f88626853a7dff563d" - }, - { - "path": "resources/js/pages/land-assistance/print.tsx", - "remote_sha256": "6355e9adb55de69deb335abff1c5d872e8993845c329f3227b81cfee4f7484c8" - }, - { - "path": "resources/js/pages/management/desk-assignments.tsx", - "remote_sha256": "4ce516c67caf67215a8f9fa2cb5ca7e7c959353fc0c0b661ef31c47751d97274" - }, - { - "path": "resources/js/pages/management/opportunity-draft.tsx", - "remote_sha256": "4281780693b7d25f2d8bb8f764e89555a1bc628b551725b671691fd0a7760f01" - }, - { - "path": "resources/js/pages/management/opportunity-resources.tsx", - "remote_sha256": "494aab642c84e3fd603cf7d0c03d8cbc317e1f52d9575d6bace00360e6334346" - }, - { - "path": "resources/js/pages/management/territory-associations.tsx", - "remote_sha256": "23997e11cc0a6735e128fdabb1453be9022a51487addaa69fe4d301a49cc2725" - }, - { - "path": "resources/js/pages/portal/manage.tsx", - "remote_sha256": "b6b96686aaccf6699551b0090a92fc40a9828affb953a8a87568d35fdc90688b" - }, - { - "path": "resources/js/pages/portal/services.tsx", - "remote_sha256": "8685509a3678323d9471e5921edcd195ca7dd3a50cc301d44222117d5df690e2" - }, - { - "path": "resources/views/desk/receipt.blade.php", - "remote_sha256": "74c52186b38f75cae100283081fad22b90b13ec69ead512c4cd4f91bc2ca3a44" - }, - { - "path": "resources/views/errors/403.blade.php", - "remote_sha256": "608ca8b0af82357afc1e6246767b777d2ec8b260f06f7f667089b1a465b24100" - }, - { - "path": "routes/api.php", - "remote_sha256": "03afdc58de664a5d42459f4dcbd7367788f49c7736ec821ebd4bfaab609c3444" - }, - { - "path": "routes/investment-zones.php", - "remote_sha256": "34b2d9c5042e59e4442906399e72e057771422840655185733fe32e8d7013737" - }, - { - "path": "routes/investment.php", - "remote_sha256": "ce048ba84c38cb4c3676c05f04853ab41686970eadf583ba849ac63d19fce011" - }, - { - "path": "routes/virtual-desk.php", - "remote_sha256": "fcef540ff90f6212b84b097492f34398c585fb03c6aeaa332943234e134cc9cb" - }, - { - "path": "routes/web.php", - "remote_sha256": "67da2d4dd4c2572f42c734bf0e538b86d0deb44180a11e8a999b325159352198" - } - ], - "unchanged_runtime_files": 676, - "runtime_files_requiring_remote_read": [], - "excluded": "All .env, storage, generated route helpers, vendor/node_modules, synthetic actors and records; no database imports. Wayfinder helpers regenerated from committed routes during build.", - "existing_diagnostic_source_preserved": [ - "app/Console/Commands/CheckDevelopmentEnvironment.php" - ], - "subsequent_diagnostic_resolution": { - "commit": "9d148ca2419d2243489731287dc8019cfe117bd9", - "decision": "Retain the newer origin/main implementation, including --wait and safe transient-failure diagnostics. The installed older implementation has no unique behaviour to discard. The reconciled baseline commit remains preserved in history." - }, - "generated_helper_not_committed": [ - { - "path": "resources/js/wayfinder/index.ts", - "remote_sha256": "3efc1c9f2eaa4828a4169414a1239420fc1bea8d3752fbd7a95b4a6da4a60d11" - } - ] + "base_commit": "507387502c6ef887433e8e9b5cf20ea39d4f17d4", + "installed_revision": "423bd512d8c5", + "approved_runtime_files_adopted": [ + { + "path": "app/Http/Controllers/Api/V1/InterestRequestController.php", + "remote_sha256": "50b65d0dd576d1c0e4357fe5a192744900eb77b2e5737191ecc553428e6ccf14" + }, + { + "path": "app/Http/Controllers/DocumentMalwareController.php", + "remote_sha256": "b19b028ae53fcb8ed648745c9de0121e128e489a9cdd1c3eb8a4210c297416d4" + }, + { + "path": "app/Http/Controllers/EditorialArticleController.php", + "remote_sha256": "41e34a865890c81191041ec1c34b0abc1ab052c99ae3b96edc930f841c5a33c5" + }, + { + "path": "app/Http/Controllers/ExistingBusinessController.php", + "remote_sha256": "e73ac0fbe1c4ae3939c27e5ca1b6e76642611a5c117c7473f21c284235fe75da" + }, + { + "path": "app/Http/Controllers/InvestmentSubmissionController.php", + "remote_sha256": "257837ff8100d5fbc3a432ec336aacc4f37af01d7044e0c44d37de2d4ec44b4c" + }, + { + "path": "app/Http/Controllers/InvestmentZoneController.php", + "remote_sha256": "32ed3b3f662b7fcfb8fce09c7626680f3409b0b3bd6acd662088e0adc22cf53b" + }, + { + "path": "app/Http/Controllers/OpportunityResourceController.php", + "remote_sha256": "da265c473a783423321fa75f1b02eb62f311b80e4cb317298d5ac4883630e2c6" + }, + { + "path": "app/Http/Controllers/PortalManagementController.php", + "remote_sha256": "c49ba1d490f88e65daae21ad0038608a71aef547d7b77acd096cdc8a4d695c0d" + }, + { + "path": "app/Http/Controllers/PortalServicesController.php", + "remote_sha256": "8e7595a42945876f9d2787a8fafaa7cec43c6dfafdc79a9aee73fa8aa533c359" + }, + { + "path": "app/Http/Controllers/VirtualDeskController.php", + "remote_sha256": "e4620b41820945512283f7f0cdd46f70916b9935288ec91a00e3d1c388af950d" + }, + { + "path": "app/Http/Middleware/EnsureDocumentManagement.php", + "remote_sha256": "7e373bffc2517366066a32889b36f277f5cea242c0f3277ef291e2c98fb8520e" + }, + { + "path": "app/Http/Middleware/EnsureHomologationAccess.php", + "remote_sha256": "259f467b08d4ad5a1605a40c5f3ccb9e41b2a879643df1f8e4814f5d0c82a8b4" + }, + { + "path": "app/Http/Middleware/EnsureScopedEditorial.php", + "remote_sha256": "62c4c16506f5e75ef76e3fa987d6b5ba42d5df953604f1aeecb89c87c7c5c7ca" + }, + { + "path": "app/Http/Middleware/EnsureScopedPortalAdministration.php", + "remote_sha256": "3842b05ddd4433b5a26c0dea6b97542b2b3846f46f0393c5515872ded43f0f61" + }, + { + "path": "app/Http/Middleware/HandleInertiaRequests.php", + "remote_sha256": "01083ba8e8aec0f0a346e6f86739155122b52fd44f736c066683f0c04c9bdc73" + }, + { + "path": "app/Http/Middleware/VerifiedOrSimpleParticipation.php", + "remote_sha256": "301e0ce37a92e0985bfb5b914aa13a32a0081f8ead88842ff4911c043553780a" + }, + { + "path": "app/Http/Requests/StoreInterestRequestRequest.php", + "remote_sha256": "a064fc0c71139b6ced9d209a490c1f339733ae9ad513c0128520b2942384eeac" + }, + { + "path": "app/Policies/ExistingBusinessPolicy.php", + "remote_sha256": "69c75db509d876d6125da7d4254ac3be6e23e0ee942dea460bd86fc73aa02def" + }, + { + "path": "app/Services/BusinessFormSchema.php", + "remote_sha256": "bf7509574c8c66da49b2f2d488a4dbc9896a15daebd78404fa4832c0322fe6f7" + }, + { + "path": "app/Services/DeclaredOriginStatistics.php", + "remote_sha256": "b5a20759938e2d5652018d6ec0b1b1d8dda6cdddbd4f7767fd5dbfa0da0e2f4c" + }, + { + "path": "app/Services/DocumentManagementAccess.php", + "remote_sha256": "6b2a0e8b75cb99f8baa9f40b4eda7cd5171348dd2126833ca3399e252baed4d0" + }, + { + "path": "app/Services/EconomicReferenceInstallation.php", + "remote_sha256": "a22050130c8cefdb18f2dafe03a4158f3e1596c5158cb9fd262abbf63c5ab144" + }, + { + "path": "app/Services/ExistingBusinessIntake.php", + "remote_sha256": "30d2325030e4024da1a15bc08085db8037fa42d09b1ece9c071386f346597937" + }, + { + "path": "app/Services/ExistingBusinessProfileCategory.php", + "remote_sha256": "46c045c19bff743a507a2efe0fc333454e82a9a48c2b355cf56834b638951737" + }, + { + "path": "app/Services/InterestWorkflow.php", + "remote_sha256": "d29080a7757c07505b418d4148cdb4abaaeb213a7b004bc21c52a135af99dca8" + }, + { + "path": "app/Services/InvestmentZoneOperationalDetailsInstallation.php", + "remote_sha256": "2b9f47cf7e1ddf14069440691485733f29cbe5fc0fb5b0091d15e96f5b8391d9" + }, + { + "path": "app/Services/InvestmentZoneProfileInstallation.php", + "remote_sha256": "026d6e1752fc8c4c80c14d5ce87bf5ea53d585a917a0cd3cd2c9d522247bb7db" + }, + { + "path": "app/Services/InvestorContext.php", + "remote_sha256": "1e7d9c7096b72a23df17611247c315ae6c8e76472c87638409be987c3cbba273" + }, + { + "path": "app/Services/LegacyContent.php", + "remote_sha256": "7138d214165859ad9ae4f4be81de9ab39978257264025a357b9b84aa90e661bc" + }, + { + "path": "app/Services/Malware/ClamDScanner.php", + "remote_sha256": "b708454c18793cbdfa761010905a0e423adfbaddeaf49ac342d88b1dc2e5d3e0" + }, + { + "path": "app/Services/OpportunityCanonicalContract.php", + "remote_sha256": "75ffb95495b630193710830f5b9913681f544baacebeac7e7be5d7f7815e59cd" + }, + { + "path": "app/Services/OpportunityCategoryFields.php", + "remote_sha256": "d7d294c72b7c416bd91726e7d7e2a045968e85d62ddaf57137da921e0340f25c" + }, + { + "path": "app/Services/OpportunityCompleteness.php", + "remote_sha256": "1497712c221332e5f879205d94470c5cbe657d543565a99f08eee4543da0ddfb" + }, + { + "path": "app/Services/OpportunityInitiativeApplicability.php", + "remote_sha256": "7efeae3c445bc3e7feb3275bd66a54a0b1370931a1331ac5ef6d7bfa58b4cdb5" + }, + { + "path": "app/Services/OpportunityLibrarySupport.php", + "remote_sha256": "2dea4f3efc76b16c4cccb97ffcfbeccbe4b3151a9a871fe8e2c175f7874fec11" + }, + { + "path": "app/Services/OpportunityResources.php", + "remote_sha256": "cf3baa0e5592f0b039189877faca514c62db65ae2e2bd634b5380c74f56ab412" + }, + { + "path": "app/Services/OpportunityTerritorialPresentation.php", + "remote_sha256": "81d91ffe07531c43e057c81887bfa2891e50c38ff7cda9414a26af67592a2b78" + }, + { + "path": "app/Services/PortalProfileInstallation.php", + "remote_sha256": "4ab58a07c93e743327c592434dc1931057baf1ba81f488f4b51a28022beec4ac" + }, + { + "path": "app/Services/ScopedPortalAdministration.php", + "remote_sha256": "013efc04ecfddaea7473f50c0d0c31f56b5d958bb1594ed913d3d3855b9b0372" + }, + { + "path": "app/Services/VerifiedDocumentBinary.php", + "remote_sha256": "3a602b504bcb26edd3c9182dcab83fef2fd25166e61a840d1503b7a4d98f0728" + }, + { + "path": "app/Services/VirtualDesk.php", + "remote_sha256": "dc958b808d91b353d2c8e7d1244f594c54fc5eb4924e64654818f1cbd1aebda9" + }, + { + "path": "bootstrap/app.php", + "remote_sha256": "b6e04e06d1d7d17f43ca81370ced7d470d5b9274957680110203f20e44ffacc1" + }, + { + "path": "config/homologation_editorial.php", + "remote_sha256": "451a24a9a93fcdc2fd2aec98fb5b8689e13de1239c9b1671906ec58eb1b4e09e" + }, + { + "path": "config/legacy_content_aliases.php", + "remote_sha256": "c8bfbead3a0908c84f7e1d595154d6d75ecebd24737651dad396e8bd0c900c50" + }, + { + "path": "config/portal.php", + "remote_sha256": "6f26c86f4599a93eb1b80bb6fa0657b72fdbc03935d2c262080858fd79287b00" + }, + { + "path": "config/portal_profiles.php", + "remote_sha256": "2e9e3d40277980c9fc139a51eb33212fe3df71a74397c353fe355a5642f99a07" + }, + { + "path": "config/services.php", + "remote_sha256": "98f2875d58ab9283018b5c8e3ba24000f2d2b8be4d67ff107ff33ceac578be7c" + }, + { + "path": "database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php", + "remote_sha256": "ff9b56f7c3d156bedfe0050953c2ea6c10811fd859524a8fb994e176527846f0" + }, + { + "path": "database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php", + "remote_sha256": "3d3e7f00df749475a67c4d098a42cbbc81f9015afc8b74e6be58c86dd1f2abfb" + }, + { + "path": "database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php", + "remote_sha256": "e12017e6a4d1a960057cf2d1df9dbd69faa867bf6a2d08a50dd3089a6a967100" + }, + { + "path": "database/migrations/2026_10_18_160000_add_portal_editorial_content.php", + "remote_sha256": "b3aa3d2510cd17975dbb4ff5f49d741af9f67601ce7e181f6173cfa2368d6334" + }, + { + "path": "resources/js/app.tsx", + "remote_sha256": "858eabfe3f5bf45a2e5a11c08e123fdd8c3243c6648c0d54def6966a0a683f88" + }, + { + "path": "resources/js/components/app-logo.tsx", + "remote_sha256": "e8e95de1d180014edc8fce2a36851727e5721319a9e9dffa90c82734f7b5939c" + }, + { + "path": "resources/js/components/context-documents.tsx", + "remote_sha256": "516871523445ea1efd2415f73c63e1ae74be2da76ceb620708bdf39fe0605999" + }, + { + "path": "resources/js/components/country-select.tsx", + "remote_sha256": "291676dc034a5f3d4d6498f1b80aa9de01b2b256f092fc855177a232b29ec714" + }, + { + "path": "resources/js/components/investment-map-panel.tsx", + "remote_sha256": "7cee11792343dd3ac94d4aeb782e6b23a29cf826b56209dcc5b4291d57b738c5" + }, + { + "path": "resources/js/components/investment-territory.tsx", + "remote_sha256": "38b2945a59601c5a1cea33827f57f105fc521ed3679223d95d5aa76cf6e13855" + }, + { + "path": "resources/js/components/opportunity-canonical-fields.tsx", + "remote_sha256": "f3c763063eee9aeb2da354c54e84c86e3c54f46dfecce4665ee0e8c6c6970d05" + }, + { + "path": "resources/js/components/opportunity-classification.tsx", + "remote_sha256": "06bdcf091f51a7eb44f75790f7d40462172571d1f1219f12620ee84a64fd668e" + }, + { + "path": "resources/js/components/opportunity-library-reuse.tsx", + "remote_sha256": "e95e3358a0016edd00596b09eeb80f5c2f0d149bdb456d8bc2c812cfb32e62f7" + }, + { + "path": "resources/js/components/partnership-search-results.tsx", + "remote_sha256": "efc037858a9a01d643894b308af8145959a7111b2667e393392dae53a3e5ba88" + }, + { + "path": "resources/js/components/password-input.tsx", + "remote_sha256": "f13f872ca86ebfd48fe475efdea32d5ec667414a4bba4529efe19a3bf81b2c16" + }, + { + "path": "resources/js/components/portal-grouped-menu.tsx", + "remote_sha256": "0c1de9b30772632afadcef6871edd6938cdab8f838983fd00cbd473d135432e7" + }, + { + "path": "resources/js/components/portal-product-identity.tsx", + "remote_sha256": "a0d5e22d06bd9dd12dce152425645f0247c7cb88767c23d5f0367e4f91c1e0cc" + }, + { + "path": "resources/js/components/search-feedback.tsx", + "remote_sha256": "bbe5b489b55b5853de553f68bdc6d38708247ca7a0649dda8a26e60148a8905d" + }, + { + "path": "resources/js/components/search-state.tsx", + "remote_sha256": "70d705f327d561c822a679e0739ce02470fbe7a18e4c8b44c9305f7619b496d6" + }, + { + "path": "resources/js/components/structure-geography.tsx", + "remote_sha256": "62f7570cf0b9149f006721ea59d694d66038530702d368444abc3589128619d4" + }, + { + "path": "resources/js/components/tempo-footer.tsx", + "remote_sha256": "14bbdc61d82cf392b43f7bf35e326cec87ddb40fbcce22d9aab9da872f746bc6" + }, + { + "path": "resources/js/hooks/use-interest-draft.ts", + "remote_sha256": "787b3295f3bee1282b4cacfefc1e7650feea54738d35f542d879a4dc13c9d475" + }, + { + "path": "resources/js/layouts/auth/auth-simple-layout.tsx", + "remote_sha256": "6c2b046bd02e7e02017a536873add53b02df79ef17798476b8021c401546462b" + }, + { + "path": "resources/js/layouts/portal-navigation.tsx", + "remote_sha256": "08876167e7de425470d6b97133441221437081b2065b2f1e6ccc9efce40a0b45" + }, + { + "path": "resources/js/lib/investor-context.ts", + "remote_sha256": "19e2e5e6ef85059c29354a0f2a14d26012fdcc9868b84144b18df5fc41a667f9" + }, + { + "path": "resources/js/lib/participation-identity.ts", + "remote_sha256": "e4446f51529d787e83beeb044eede9a3e5f3894cf7f9c51d97542c403f563b6c" + }, + { + "path": "resources/js/lib/portal-api.ts", + "remote_sha256": "2ad0ad502e7805ae834e2368982f17b90cd9e32e4c0de112d14c1f3ef9268ca8" + }, + { + "path": "resources/js/lib/portal-navigation.ts", + "remote_sha256": "17e541f5f0add27bf4af1dc6dae0ce7cdeab96de0162f1b3663cbf4dea2d8c85" + }, + { + "path": "resources/js/lib/project-dossier-document.ts", + "remote_sha256": "28e2ac9929038f9fdbc2fe5bf8dde46e4ff477db29964dc7921381bfbf00cc65" + }, + { + "path": "resources/js/lib/project-dossier-report.ts", + "remote_sha256": "f4a428cd337912df60ec3029e95c6fe9beaf2d3f81cca903690c626828719f22" + }, + { + "path": "resources/js/pages/PortalInvestidor.tsx", + "remote_sha256": "18885a653fe13ea9040a998fb721a9e21a77fc3ead359378fac7b7a740eb9c5d" + }, + { + "path": "resources/js/pages/auth/login.tsx", + "remote_sha256": "3f9231ba2cb69216c61215c701e8929ff9b90fb164f9a179f42d00b7c30c3aa7" + }, + { + "path": "resources/js/pages/auth/register.tsx", + "remote_sha256": "6ea92f1ec50058e39af8901b78256f4c99fcfbefd0f9ebb299acdff05c99d50a" + }, + { + "path": "resources/js/pages/desk/request.tsx", + "remote_sha256": "7b3b8bec1a66a0403e6cdf9a11cf83d59b6a529f5f43b6a5871ba76c1bc7935d" + }, + { + "path": "resources/js/pages/investment/business-interest.tsx", + "remote_sha256": "0cfbde1ba8e4cd164bd2a904c9f5560e4603609c71854b3fdb6db91561b883c4" + }, + { + "path": "resources/js/pages/investment/business-wizard.tsx", + "remote_sha256": "03ce4217bcb34583ceeeab454e40b1ee8aa73ba361d24e833e3c16e5fa165ec4" + }, + { + "path": "resources/js/pages/investment/business.tsx", + "remote_sha256": "3682717821510a19f7e02c14179d72144f50ee822d96ebd011e48d37d6ab2055" + }, + { + "path": "resources/js/pages/investment/catalogue.tsx", + "remote_sha256": "7ac06e80303992fae94b6ba2313ede5cff13da974815460cf59100b07b1ce338" + }, + { + "path": "resources/js/pages/investment/context.tsx", + "remote_sha256": "50b86276a3ac238b678f06dfb536b61ca3711a495453debae2e795082cdc0053" + }, + { + "path": "resources/js/pages/investment/detail.tsx", + "remote_sha256": "743de540cd1caeda5f3afb2b2dc78c088ea56ebe527a5466a3d039b430548688" + }, + { + "path": "resources/js/pages/investment/submission.tsx", + "remote_sha256": "71f03043fd16a806632fa7c0bf503998f0dff99fdd1bbfcaa703a032a2fa6558" + }, + { + "path": "resources/js/pages/investment/zones.tsx", + "remote_sha256": "a1f0b395f027a05ecef134e1bd0c03a20a23b089c7d8c5aa45403eb1c7920a92" + }, + { + "path": "resources/js/pages/investor-path/context-start.tsx", + "remote_sha256": "36d95c80a64a09ee40bd0fec8f6c0f623a47e576667fe08fadf2e3dc8969691e" + }, + { + "path": "resources/js/pages/investor-path/context.tsx", + "remote_sha256": "823da4d6092f553fef7aa801fb1c7d809b53b2c96571aaaeffece67a92af454a" + }, + { + "path": "resources/js/pages/investor-path/show.tsx", + "remote_sha256": "5d56de96fe41ad5b8782a4f1c472c110cbb3e93493bc92f88626853a7dff563d" + }, + { + "path": "resources/js/pages/land-assistance/print.tsx", + "remote_sha256": "6355e9adb55de69deb335abff1c5d872e8993845c329f3227b81cfee4f7484c8" + }, + { + "path": "resources/js/pages/management/desk-assignments.tsx", + "remote_sha256": "4ce516c67caf67215a8f9fa2cb5ca7e7c959353fc0c0b661ef31c47751d97274" + }, + { + "path": "resources/js/pages/management/opportunity-draft.tsx", + "remote_sha256": "4281780693b7d25f2d8bb8f764e89555a1bc628b551725b671691fd0a7760f01" + }, + { + "path": "resources/js/pages/management/opportunity-resources.tsx", + "remote_sha256": "494aab642c84e3fd603cf7d0c03d8cbc317e1f52d9575d6bace00360e6334346" + }, + { + "path": "resources/js/pages/management/territory-associations.tsx", + "remote_sha256": "23997e11cc0a6735e128fdabb1453be9022a51487addaa69fe4d301a49cc2725" + }, + { + "path": "resources/js/pages/portal/manage.tsx", + "remote_sha256": "b6b96686aaccf6699551b0090a92fc40a9828affb953a8a87568d35fdc90688b" + }, + { + "path": "resources/js/pages/portal/services.tsx", + "remote_sha256": "8685509a3678323d9471e5921edcd195ca7dd3a50cc301d44222117d5df690e2" + }, + { + "path": "resources/views/desk/receipt.blade.php", + "remote_sha256": "74c52186b38f75cae100283081fad22b90b13ec69ead512c4cd4f91bc2ca3a44" + }, + { + "path": "resources/views/errors/403.blade.php", + "remote_sha256": "608ca8b0af82357afc1e6246767b777d2ec8b260f06f7f667089b1a465b24100" + }, + { + "path": "routes/api.php", + "remote_sha256": "03afdc58de664a5d42459f4dcbd7367788f49c7736ec821ebd4bfaab609c3444" + }, + { + "path": "routes/investment-zones.php", + "remote_sha256": "34b2d9c5042e59e4442906399e72e057771422840655185733fe32e8d7013737" + }, + { + "path": "routes/investment.php", + "remote_sha256": "ce048ba84c38cb4c3676c05f04853ab41686970eadf583ba849ac63d19fce011" + }, + { + "path": "routes/virtual-desk.php", + "remote_sha256": "fcef540ff90f6212b84b097492f34398c585fb03c6aeaa332943234e134cc9cb" + }, + { + "path": "routes/web.php", + "remote_sha256": "67da2d4dd4c2572f42c734bf0e538b86d0deb44180a11e8a999b325159352198" + } + ], + "unchanged_runtime_files": 676, + "runtime_files_requiring_remote_read": [], + "excluded": "All .env, storage, generated route helpers, vendor/node_modules, synthetic actors and records; no database imports. Wayfinder helpers regenerated from committed routes during build.", + "existing_diagnostic_source_preserved": [ + "app/Console/Commands/CheckDevelopmentEnvironment.php" + ], + "subsequent_diagnostic_resolution": { + "commit": "9d148ca2419d2243489731287dc8019cfe117bd9", + "decision": "Retain the newer origin/main implementation, including --wait and safe transient-failure diagnostics. The installed older implementation has no unique behaviour to discard. The reconciled baseline commit remains preserved in history." + }, + "generated_helper_not_committed": [ + { + "path": "resources/js/wayfinder/index.ts", + "remote_sha256": "3efc1c9f2eaa4828a4169414a1239420fc1bea8d3752fbd7a95b4a6da4a60d11" + } + ] } diff --git a/deploy/homologacao/webhook.php b/deploy/homologacao/webhook.php index dc461c1a..f1f8da53 100644 --- a/deploy/homologacao/webhook.php +++ b/deploy/homologacao/webhook.php @@ -1,39 +1,60 @@ $commit, 'ref' => $payload['ref'], 'received_at' => gmdate(DATE_ATOM)], JSON_THROW_ON_ERROR); -if (file_put_contents($temporary, $data) === false || !rename($temporary, $file)) { - @unlink($temporary); http_response_code(503); exit('Queue unavailable'); +if (file_put_contents($temporary, $data) === false || ! rename($temporary, $file)) { + @unlink($temporary); + http_response_code(503); + exit('Queue unavailable'); } // systemd.path observes the queue even if a previous build is still active. http_response_code(202); diff --git a/deploy/local/compose.yaml b/deploy/local/compose.yaml index 88709cce..fb2c1ee8 100644 --- a/deploy/local/compose.yaml +++ b/deploy/local/compose.yaml @@ -14,7 +14,11 @@ services: volumes: - postgres-qa:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -p 55441 -U investmoz_test -d investmoz_local_test'] + test: + [ + 'CMD-SHELL', + 'pg_isready -p 55441 -U investmoz_test -d investmoz_local_test', + ] interval: 5s timeout: 5s start_period: 600s diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 83fb6150..80e51a97 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -2033,3 +2033,13 @@ reversão e limites no início do [manual existente](unification/traceability-an As entregas posteriores de Competitividade e dos filtros provinciais ficam disponíveis nesta pasta; não se declara nova instalação ou aceitação visual. Mantidos os 3.555 requisitos da matriz CSV, os recibos e os históricos fiscais. + +### 29/10/2026 — correcções e validação automática local após reconciliação + +Validação técnica local concluída na branch `codex/investemoz-reconciliation-20261029`, a partir de `63147312`. Cobertura conferida contra o inventário completo: **798 testes PHP** (91 Unit, 706 Feature em 216 classes e 1 ensaio de schema com 3106 asserções) e **1190 testes frontend**. Tipos TypeScript, PHPStan nível 7 sem erros, Pint, lint/formatação, compilação e os controlos positivos/negativos C132 passaram. Resultados de repetições consolidados por identificador de teste, sem duplicar contagens e sem novos skips. + +Correcções incluem contratos de dados e falhas explícitas de leitura/hash, continuidade do contexto do dossier no simulador, invalidação do cache actual da página inicial, leitura de originais antes do carregamento do catálogo opcional e directório mínimo de contas na administração autorizada. A classificação territorial reutiliza directamente o âmbito público existente, eliminando a subconsulta recursiva redundante; a varredura de rotas com o catálogo territorial completo passou em cerca de 35 segundos, com limite QA de 20 segundos por consulta. Mantidos os testes de exclusão de referências privadas/não aprovadas. Fixtures alinhadas com autenticação, MFA recente, publicação explícita e separação dos snapshots pessoal/económico. O fake HTTP de assets também isola manifestos de fontes mutáveis. + +Execução exclusivamente em bases QA isoladas em `127.0.0.1:55441`, com contextos vazios ou clones preparados conforme as guardas originais. Os 28 originais editoriais foram recuperados de arquivo local e conferidos por SHA-256; seis testes editoriais passaram com scanner real. Preservados os originais, arquivos de fontes, ferramentas históricas, chave da aplicação, dados activos e históricos. Zero migrações/importações/escritas na base activa, push ou deployment. A alteração inclui formatação abrangente do código mantido; o manifesto de hashes identifica os ficheiros validados. + +**Conferência visual autenticada pendente:** a revisão automática interrompeu Computer Use por não conseguir determinar com confiança o URL actual do Chrome. Não foi declarado aceite visual de Competitividade nem encerramento transversal de ACT-01, ACT-02 ou V2-07/V2-10. Recibo e provas: [local-validation-20261029.json](unification/local-validation-20261029.json). diff --git a/docs/investmoz/unification/local-validation-20261029.json b/docs/investmoz/unification/local-validation-20261029.json new file mode 100644 index 00000000..0f282396 --- /dev/null +++ b/docs/investmoz/unification/local-validation-20261029.json @@ -0,0 +1,542 @@ +{ + "recorded_at": "2026-10-29T19:46:20.694141+00:00", + "repository": "InvesteMoz / portal-investidor", + "base_commit": "63147312e2fdb6dc235bcb2763f47d8b1aeeac3c", + "branch": "codex/investemoz-reconciliation-20261029", + "status": "automated_validation_passed_visual_validation_pending", + "validated_source": { + "manifest": "outputs/validation-20261029/validated-source-manifest.json", + "sha256": "1291c22692f90f9baaa85e5f252d5db2301dccb83d6d887fa76a0efae1618ba1", + "files": 715, + "includes_maintained_code_formatting": true + }, + "totals": { + "php_default_inventory": 797, + "php_unit": 91, + "php_feature": 706, + "schema_lifecycle_additional": 1, + "php_total_with_schema": 798, + "frontend": 1190, + "feature_classes": 216, + "new_skips": 0, + "remaining_automated_failures": 0 + }, + "checks": { + "unit-verified": { + "result": "passed", + "tests": 91, + "passed": 91, + "assertions": 405 + }, + "fresh-corrections": { + "result": "passed", + "tests": 3, + "passed": 3, + "assertions": 298 + }, + "simulator-storage": { + "result": "passed", + "tests": 15, + "passed": 15, + "assertions": 445 + }, + "simulator-corrections": { + "result": "passed", + "tests": 13, + "passed": 13, + "assertions": 810 + }, + "proactive-retry": { + "result": "passed", + "tests": 16, + "passed": 16, + "assertions": 157 + }, + "remaining-final": { + "result": "passed", + "tests": 63, + "passed": 63, + "assertions": 447 + }, + "fiscal-clean": { + "result": "passed", + "tests": 24, + "passed": 24, + "assertions": 291 + }, + "schema": { + "result": "passed", + "tests": 1, + "passed": 1, + "assertions": 3106 + }, + "editorial-final": { + "result": "passed", + "tests": 6, + "passed": 6, + "assertions": 1336 + }, + "routes-final": { + "result": "passed", + "tests": 1, + "passed": 1, + "assertions": 6 + }, + "simulator_consolidated": { + "result": "passed", + "tests": 171, + "passed": 171, + "evidence_method": "Distinct testcase outcomes consolidated from complete initial run and complete passing replacement classes.", + "source_junit": [ + "simulator-verified.junit.xml", + "simulator-corrections.junit.xml" + ] + }, + "fresh_consolidated": { + "result": "passed", + "tests": 350, + "passed": 350, + "evidence_method": "Distinct testcase outcomes consolidated from complete initial run and complete passing replacement classes.", + "source_junit": [ + "fresh-verified.junit.xml", + "fresh-corrections.junit.xml" + ] + } + }, + "static_checks": { + "phpstan": { + "level": 7, + "errors": 0 + }, + "pint": "passed", + "typescript": "passed", + "lint": { + "files": 633, + "errors": 0, + "warnings": 0 + }, + "frontend_format": { + "files": 655, + "result": "passed" + }, + "production_build": "passed", + "build_notes": [ + "Existing optional font fallback and bundle-size/timing advisories remain non-fatal." + ], + "c132": { + "result": "passed", + "case": "C132", + "source_explicit_constant_OFF": true, + "effective_configuration_OFF": true, + "configuration_cached": false, + "database_connections_used": 0, + "active_writes": 0 + }, + "c132_negative_controls": { + "result": "passed", + "case": "C132", + "checks": { + "safe": "passed", + "missing_source": "passed", + "enabled_source": "passed", + "stale_cache_enabled": "passed", + "missing_effective": "passed", + "constant_false": "passed", + "comment_only": "passed", + "environment_switch": "passed" + }, + "active_writes": 0 + } + }, + "coverage": { + "feature_groups": { + "fresh": [ + "tests/Feature/ApiContractTest.php", + "tests/Feature/BusinessMarketplacePublicTest.php", + "tests/Feature/ClamDQueueWorkerIntegrationTest.php", + "tests/Feature/ClusterValueChainTest.php", + "tests/Feature/CountryCandidateMfaTest.php", + "tests/Feature/CrmIntakeTest.php", + "tests/Feature/DashboardOpportunityAccessTest.php", + "tests/Feature/DashboardTest.php", + "tests/Feature/DevelopmentEnvironmentTest.php", + "tests/Feature/DocumentLibrarySchemaCompatibilityTest.php", + "tests/Feature/ExampleTest.php", + "tests/Feature/ExistingBusinessCompatibilityTest.php", + "tests/Feature/ExistingBusinessFoundationTest.php", + "tests/Feature/ExistingBusinessIntakeTest.php", + "tests/Feature/ExistingBusinessWizardTest.php", + "tests/Feature/ImportDocumentBatchTest.php", + "tests/Feature/ImportLegalSourceArchiveTest.php", + "tests/Feature/IndependentDocumentOperationsTest.php", + "tests/Feature/InterestSubmissionTest.php", + "tests/Feature/InvestmentPortfolioTest.php", + "tests/Feature/InvestmentRelationshipTest.php", + "tests/Feature/InvestmentStructureTest.php", + "tests/Feature/InvestmentTerritorialTraceabilityTest.php", + "tests/Feature/InvestmentTerritoryTest.php", + "tests/Feature/InvestmentUnificationTest.php", + "tests/Feature/InvestmentWorkspaceTest.php", + "tests/Feature/InvestmentZoneAttachmentTest.php", + "tests/Feature/InvestmentZoneTest.php", + "tests/Feature/InvestorGuidanceTest.php", + "tests/Feature/InvestorPathTest.php", + "tests/Feature/LandAssistanceTest.php", + "tests/Feature/LegacyContentTest.php", + "tests/Feature/ManagementAccessTest.php", + "tests/Feature/OpportunityCanonicalWorkflowTest.php", + "tests/Feature/OpportunityManagementTest.php", + "tests/Feature/OpportunityOriginReviewTest.php", + "tests/Feature/OpportunityPublicationScopeTest.php", + "tests/Feature/OpportunityRealDocumentOperationsTest.php", + "tests/Feature/OpportunityRealEstateDraftTest.php", + "tests/Feature/OpportunityResourcesTest.php", + "tests/Feature/OrganizationCountryInstallationTest.php", + "tests/Feature/OrganizationCountryTest.php", + "tests/Feature/PartnershipExchangeTest.php", + "tests/Feature/PortalAuditConsumerLotTest.php", + "tests/Feature/PortalAuditContinuationTest.php", + "tests/Feature/PortalAuditNextLotTest.php", + "tests/Feature/PortalAuditRoutesTest.php", + "tests/Feature/PortalManagementTest.php", + "tests/Feature/PortalOperationsTest.php", + "tests/Feature/PortalOriginCompatibilityTest.php", + "tests/Feature/PortalProfileAccessTest.php", + "tests/Feature/PortalProfileInstallationTest.php", + "tests/Feature/PortalQaMfaTracePrivacyTest.php", + "tests/Feature/PortalRealDocumentConsumersTest.php", + "tests/Feature/PortalServicesTest.php", + "tests/Feature/PublicPortalNavigationTest.php", + "tests/Feature/ReferenceCatalogTest.php", + "tests/Feature/SimulationDraftContinuityTest.php", + "tests/Feature/SimulationExecutionTest.php", + "tests/Feature/SimulationFormDraftTest.php", + "tests/Feature/SimulationTaxRuleGovernanceTest.php", + "tests/Feature/SkillsEducationSchemaTest.php", + "tests/Feature/TerritorialRegistryTest.php", + "tests/Feature/TerritoryGeometryImportTest.php" + ], + "simulator": [ + "tests/Feature/CatalogueSelectorReferencesTest.php", + "tests/Feature/ContentManagementEntryTest.php", + "tests/Feature/CorridorAgribusinessBatchTest.php", + "tests/Feature/CorridorAgribusinessInvestorPathTest.php", + "tests/Feature/CorridorAgribusinessOpportunitiesTest.php", + "tests/Feature/InvestmentAnchorIntegrationTest.php", + "tests/Feature/InvestmentComplementsTest.php", + "tests/Feature/InvestmentConsumerScopeTest.php", + "tests/Feature/InvestmentContextContinuityTest.php", + "tests/Feature/InvestmentCorridorContextTest.php", + "tests/Feature/InvestmentOpportunityChoicesTest.php", + "tests/Feature/InvestmentPortfolioContinuityTest.php", + "tests/Feature/InvestmentTreatmentScopeTest.php", + "tests/Feature/IrpcAnnualSettlementDraftTest.php", + "tests/Feature/IrpcGroupedDraftTest.php", + "tests/Feature/IrpcInterestCerDraftTest.php", + "tests/Feature/IrpcMrftaDraftTest.php", + "tests/Feature/IrpcNormalAssessmentDraftTest.php", + "tests/Feature/IrpcNormalBaseDraftTest.php", + "tests/Feature/IrpcNormalCostsDraftTest.php", + "tests/Feature/IrpcNormalDepreciationDraftTest.php", + "tests/Feature/IrpcNormalDraftTest.php", + "tests/Feature/IrpcNormalFormPreparationDraftTest.php", + "tests/Feature/IrpcNormalPeriodFxDraftTest.php", + "tests/Feature/IrpcNormalProfitDraftTest.php", + "tests/Feature/IrpcNormalSchedulesDraftTest.php", + "tests/Feature/IrpcReliefsDraftTest.php", + "tests/Feature/IrpcSpecialCessationPriorDraftTest.php", + "tests/Feature/IrpcSpecialCombinationsDraftTest.php", + "tests/Feature/IrpcSpecialDraftTest.php", + "tests/Feature/IrpcSpecialMonthlyDraftTest.php", + "tests/Feature/IrpcSpecialStartPeriodDraftTest.php", + "tests/Feature/IrpcWithholdingDraftTest.php", + "tests/Feature/IspcQuarterDraftTest.php", + "tests/Feature/IspcResidualDraftTest.php", + "tests/Feature/IvaAcquisitionAllocationDraftTest.php", + "tests/Feature/IvaAcquisitionBCDraftTest.php", + "tests/Feature/IvaAcquisitionContinuityDraftTest.php", + "tests/Feature/IvaAcquisitionRightDraftTest.php", + "tests/Feature/IvaActSaleBaseDraftTest.php", + "tests/Feature/IvaAgroDraftTest.php", + "tests/Feature/IvaAnnexIDraftTest.php", + "tests/Feature/IvaBaggageDraftTest.php", + "tests/Feature/IvaBeveragePartitionDraftTest.php", + "tests/Feature/IvaBeverageRestitutionDraftTest.php", + "tests/Feature/IvaClosureDraftTest.php", + "tests/Feature/IvaCommissionBaseDraftTest.php", + "tests/Feature/IvaCompositionBaseDraftTest.php", + "tests/Feature/IvaConditionalDraftTest.php", + "tests/Feature/IvaCulturalDraftTest.php", + "tests/Feature/IvaCurrencyEventDraftTest.php", + "tests/Feature/IvaDigitalDraftTest.php", + "tests/Feature/IvaExclusionBaseDraftTest.php", + "tests/Feature/IvaExportBeneficiaryDraftTest.php", + "tests/Feature/IvaExportDirectDraftTest.php", + "tests/Feature/IvaExportServiceDraftTest.php", + "tests/Feature/IvaForeignOperationDraftTest.php", + "tests/Feature/IvaFuelBaseDraftTest.php", + "tests/Feature/IvaFundraisingDraftTest.php", + "tests/Feature/IvaGoodsAllocationDraftTest.php", + "tests/Feature/IvaGoodsLocationDraftTest.php", + "tests/Feature/IvaGoodsPresumptionDraftTest.php", + "tests/Feature/IvaHealthGoodsDraftTest.php", + "tests/Feature/IvaImportAssetsDraftTest.php", + "tests/Feature/IvaImportBeneficiariesDraftTest.php", + "tests/Feature/IvaImportCustomsDraftTest.php", + "tests/Feature/IvaImportDevelopmentDraftTest.php", + "tests/Feature/IvaImportExemptionsDraftTest.php", + "tests/Feature/IvaImportInvestmentDraftTest.php", + "tests/Feature/IvaImportServicesDraftTest.php", + "tests/Feature/IvaImportSpecialRegimesDraftTest.php", + "tests/Feature/IvaImportSuppliesDraftTest.php", + "tests/Feature/IvaInternationalAssetDraftTest.php", + "tests/Feature/IvaListedGoodsDraftTest.php", + "tests/Feature/IvaListedServicesDraftTest.php", + "tests/Feature/IvaMinerDraftTest.php", + "tests/Feature/IvaNoncashBaseDraftTest.php", + "tests/Feature/IvaObjectiveImportDraftTest.php", + "tests/Feature/IvaOfferPathsDraftTest.php", + "tests/Feature/IvaOfferPreparationsDraftTest.php", + "tests/Feature/IvaOfferWorkflowDraftTest.php", + "tests/Feature/IvaOnboardSupplyDraftTest.php", + "tests/Feature/IvaOperationsDraftTest.php", + "tests/Feature/IvaOwnNameDraftTest.php", + "tests/Feature/IvaPharmaInputsDraftTest.php", + "tests/Feature/IvaProductionCalendarDraftTest.php", + "tests/Feature/IvaProductionDraftTest.php", + "tests/Feature/IvaPropertyTransferDraftTest.php", + "tests/Feature/IvaPublicationsDraftTest.php", + "tests/Feature/IvaPublicAuthorityBaseDraftTest.php", + "tests/Feature/IvaRationalDraftTest.php", + "tests/Feature/IvaReducedBasesDraftTest.php", + "tests/Feature/IvaRefundDraftTest.php", + "tests/Feature/IvaRegularizationDraftTest.php", + "tests/Feature/IvaRemissionDraftTest.php", + "tests/Feature/IvaSecondhandDraftTest.php", + "tests/Feature/IvaServiceLocationDraftTest.php", + "tests/Feature/IvaServiceNormalDraftTest.php", + "tests/Feature/IvaServicesGroupDraftTest.php", + "tests/Feature/IvaServicesNatureDraftTest.php", + "tests/Feature/IvaSocialDraftTest.php", + "tests/Feature/IvaSpecialRegimeDraftTest.php", + "tests/Feature/IvaSubstitutionDraftTest.php", + "tests/Feature/IvaTemporaryBenefitDraftTest.php", + "tests/Feature/IvaTravelEventDraftTest.php", + "tests/Feature/IvaValueReviewDraftTest.php", + "tests/Feature/LegacyEditorialWorkflowTest.php", + "tests/Feature/ModeloAAcquisitionsDraftTest.php", + "tests/Feature/OpportunityCorridorExplorerTest.php", + "tests/Feature/OpportunityPromotionTest.php", + "tests/Feature/PrivateInvestmentPreparationTest.php", + "tests/Feature/ProcurementTest.php", + "tests/Feature/SimulatorClosureRecoveryTest.php", + "tests/Feature/TerritoryGeometryApprovedAdministrationTest.php", + "tests/Feature/TerritoryGeometryConsumerAccessTest.php" + ], + "proactive": [ + "tests/Feature/CatalogueProvinceCoverageTest.php", + "tests/Feature/FiscalFormEntryTest.php", + "tests/Feature/InvestorUsabilityScopeTest.php", + "tests/Feature/IrpcCreditCarryDraftTest.php", + "tests/Feature/IrpcEconomicCreditDraftTest.php", + "tests/Feature/IrpcInternationalCreditDraftTest.php", + "tests/Feature/IrpcPecDraftTest.php", + "tests/Feature/ProactiveInvestmentTest.php", + "tests/Feature/ProactiveSupplyTest.php" + ], + "other": [ + "tests/Feature/AiGatewayTest.php", + "tests/Feature/ClamDScannerIntegrationTest.php", + "tests/Feature/ClusterCompetitivenessTest.php", + "tests/Feature/DatabaseErrorRedactionTest.php", + "tests/Feature/EducationalGuidesTest.php", + "tests/Feature/EducationTest.php", + "tests/Feature/OpportunitySearchTest.php", + "tests/Feature/ProvinceSearchCompatibilityTest.php", + "tests/Feature/SectorCatalogueContentTest.php", + "tests/Feature/SimulationFiscalMaintenanceActorTest.php", + "tests/Feature/SimulationFiscalQaPreviewTest.php", + "tests/Feature/SimulationPublishedFiscalTest.php", + "tests/Feature/SimulationRateLimitTest.php", + "tests/Feature/SimulatorCashflowContinuityTest.php", + "tests/Feature/SimulatorReviewMessagesTest.php" + ] + }, + "additional_recursive_feature_classes": [ + "tests/Feature/Auth/AuthenticationTest.php", + "tests/Feature/Auth/EmailVerificationTest.php", + "tests/Feature/Auth/PasswordConfirmationTest.php", + "tests/Feature/Auth/PasswordResetTest.php", + "tests/Feature/Auth/QaAccountSelectionTest.php", + "tests/Feature/Auth/RegistrationTest.php", + "tests/Feature/Auth/TwoFactorChallengeTest.php", + "tests/Feature/Auth/TwoFactorDecryptionFailureTest.php", + "tests/Feature/Auth/VerificationNotificationTest.php", + "tests/Feature/Settings/ProfileUpdateTest.php", + "tests/Feature/Settings/SecurityTest.php", + "tests/Feature/SuiteCrm/SuiteCrmClientTest.php", + "tests/Feature/SuiteCrm/SuiteCrmProbeTest.php" + ], + "other_group_explanation": "Fiscal maintenance, QA preview and published fiscal classes require an empty QA schema; the source-rich clone already contains protected rule identities. Those complete classes passed in fiscal-clean.log. SectorCatalogueContentTest passed in remaining-final.log. The single proactive failure passed with its complete 16-case class in proactive-retry.log. No original fixed-database guard was weakened." + }, + "data_preservation": { + "active_database_writes": 0, + "active_migrations": 0, + "active_imports": 0, + "remote_pushes": 0, + "deployments": 0, + "application_key_changes": 0, + "original_sources_and_historical_tools_unchanged": true, + "qa_host": "127.0.0.1", + "qa_port": 55441, + "original_qa_cluster_read_only_export": true, + "original_qa_cluster_stopped_after_export": true, + "editorial_originals_recovered_and_sha256_verified": 28, + "editorial_source_archives_unchanged": true, + "qa_fixture_data_is_not_acceptance_content": true + }, + "fixes": [ + "Explicit collection, scalar, query-row and response contracts without analysis suppressions.", + "Checked file/hash/size and regular-expression failures; invalid Unicode is preserved and rejected explicitly.", + "Public contact JSON endpoint avoids unrelated profile/navigation database access.", + "Guarded editorial migrations admit only the existing verified isolated QA identity; negative production/host/port/name/URL/driver tests pass.", + "Idempotent interest acknowledgements preserve the current workflow state and use the existing required authentication and UUID contract.", + "Simulator provenance retains dossier opportunity choices while selected-consumer fields and requirements remain scoped.", + "Home cache invalidation includes the current sector-organization cache key.", + "Location classification uses the authoritative public territorial query directly, avoiding a redundant recursive self-subquery while retaining public-ancestor and publication restrictions.", + "Original article reading remains available before loading the optional agribusiness opportunity catalogue.", + "Authorized profile administration recovers the minimal existing-account directory and selection hints without exposing authentication secrets.", + "Regression fixtures use authenticated actors, recent MFA where required, explicit publication and current public document projections; contact fields are partitioned into the personal snapshot.", + "Laravel HTTP asset fakes also disable mutable Vite font manifests; the build/test race was removed without changing production fonts.", + "Maintained PHP/frontend formatting and lint findings corrected; source archives, generated code and historical evidence retained." + ], + "limitations": [ + { + "area": "Authenticated visual validation / Competitiveness", + "status": "pending", + "reason": "Automatic Computer Use review could not determine the current Chrome URL with sufficient confidence. UI automation stopped; no bypass or visual acceptance claim." + }, + { + "area": "Delivery", + "status": "local_only", + "reason": "No push, deployment, production migration or editorial publication was requested or performed." + } + ], + "requirements": "Technical validation only. Does not close ACT-01, ACT-02, V2-07/V2-10 or replace visual/content acceptance.", + "evidence": [ + { + "path": "outputs/validation-20261029/unit-verified.log", + "sha256": "caab7fe5f23a2f5374cb1b243ef8f9e53d588c0f7d4633c083e2af6a9924fb38" + }, + { + "path": "outputs/validation-20261029/fresh-verified.log", + "sha256": "898b13c9910c106fa80d3ce995bf7874054a8a8d7d72712f58e87c9a7510e4c7" + }, + { + "path": "outputs/validation-20261029/fresh-corrections.log", + "sha256": "b83b6ce6d7b85df6ce28c6f448812138d9448b57122bbe1e91db710bd49a9f0a" + }, + { + "path": "outputs/validation-20261029/fresh-consolidated.junit.xml", + "sha256": "3f5f71db80fdf417613ad845c51e74cb058591b8371ee33672bd430fff3e08d3" + }, + { + "path": "outputs/validation-20261029/simulator-verified.log", + "sha256": "396eecbac488062c70ff300f868b9ecf0bf649ab2eb6db0aa090d602d2257d09" + }, + { + "path": "outputs/validation-20261029/simulator-corrections.log", + "sha256": "a7a8c7a4671eeca464c77600394d2dc9dc8ccdd1650d480b8844e15bee34307f" + }, + { + "path": "outputs/validation-20261029/simulator-consolidated.junit.xml", + "sha256": "2af02b152d295469c81c2936aeb98f0f82b68462dc3368a576d12dcfa1419511" + }, + { + "path": "outputs/validation-20261029/simulator-storage.log", + "sha256": "db95e5152d2d94bc45944bb710d97fd8cbe59dccecb4d7c71d27b1069262498f" + }, + { + "path": "outputs/validation-20261029/proactive-final.log", + "sha256": "918b530b7e0479c72f4a3fcbe05ed2aae2218a0fe66b5344002ad0a94aebd944" + }, + { + "path": "outputs/validation-20261029/proactive-retry.log", + "sha256": "179a15860d17421136d5e4f1cb30750a89e20a88df783b057a287bbb3af19d6f" + }, + { + "path": "outputs/validation-20261029/other-final.log", + "sha256": "33ca85df50db88017110cf67bd284ee785f69ce9726da4acb9f7e168ad0cd6d9" + }, + { + "path": "outputs/validation-20261029/fiscal-clean.log", + "sha256": "470046e008195cc2af3bea722d2a2b3e0a1175912d14c1096e6cb75cbb0606c7" + }, + { + "path": "outputs/validation-20261029/remaining-final.log", + "sha256": "ec2d1ac9aebcaf2c01e2787f37dd31577c740ea6c72f931711626531825df68a" + }, + { + "path": "outputs/validation-20261029/schema.log", + "sha256": "f7f155a4d3d5bd22e98f5231e69d19b569dee19bd56164822b2834d87a211f57" + }, + { + "path": "outputs/validation-20261029/frontend-verified.log", + "sha256": "3b2c56e930c6f6caae69076b3dcecd33423c41d09fd48945bd3c0524f7b40f93" + }, + { + "path": "outputs/validation-20261029/phpstan-final-verified.log", + "sha256": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2" + }, + { + "path": "outputs/validation-20261029/pint-verified.log", + "sha256": "cd1a94fc2cf6a965b86e1a4809d6c7fb9148b1ee374e1010ed2ac96ff4876ec2" + }, + { + "path": "outputs/validation-20261029/pint-fixture-verified.log", + "sha256": "cd1a94fc2cf6a965b86e1a4809d6c7fb9148b1ee374e1010ed2ac96ff4876ec2" + }, + { + "path": "outputs/validation-20261029/pint-territory-verified.log", + "sha256": "cd1a94fc2cf6a965b86e1a4809d6c7fb9148b1ee374e1010ed2ac96ff4876ec2" + }, + { + "path": "outputs/validation-20261029/pint-last-fixture-verified.log", + "sha256": "cd1a94fc2cf6a965b86e1a4809d6c7fb9148b1ee374e1010ed2ac96ff4876ec2" + }, + { + "path": "outputs/validation-20261029/routes-final.log", + "sha256": "63481f3b07a7ec555694d7e1e4b3e72d287e7db03752bc1c9ef1c3dc3e861418" + }, + { + "path": "outputs/validation-20261029/territorial-query-qa.json", + "sha256": "7f329807105e166bc2eaef43383ac9de189a7e8f5cd452e1943924671991217f" + }, + { + "path": "outputs/validation-20261029/check-final-verified.log", + "sha256": "f3dc103584f6845d61a8ce8954c66685a1c0500123cfe738a6d67bb1dd10cfbd" + }, + { + "path": "outputs/validation-20261029/types-latest.log", + "sha256": "5346ac8a6566c1c38cb0bbda6a029579df58cd78af78e2c4845d9c82b5d1d76c" + }, + { + "path": "outputs/validation-20261029/build-verified.log", + "sha256": "adf5a5136e1ea02dcfa8e054f2303d9e115d9ec65bc6c765a5d618a614783f05" + }, + { + "path": "outputs/validation-20261029/editorial-final.log", + "sha256": "fb50ba0514a7c0aceaa0c84650693debb817d2732179000799bc4480ef436545" + }, + { + "path": "outputs/validation-20261029/editorial-originals-recovery.json", + "sha256": "9f9998a06fee6cde241cca54f5a8060283934328721249a5ad00bcddefa50b9c" + } + ], + "qa_runtime_cleanup": { + "temporary_preview_server_stopped": true, + "temporary_postgresql_cluster_stopped": true, + "existing_application_processes_stopped": 0, + "qa_backups_and_evidence_retained": true + } +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 71511afe..c9d5722b 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -7570,3 +7570,13 @@ Recibo: docs/investmoz/unification/province-search-qa-20261029.json. Instalaçã **Separação mantida:** este resultado encerra a lacuna técnica de compatibilidade/expansão provincial descrita acima, sem declarar conferência visual dos controlos e sem encerrar os requisitos amplos ACT-02/V2-07/V2-10. A conferência visual autenticada de Competitividade continua **pendente — execução e registo pela equipa**. As provas aceites dessa funcionalidade não foram reabertas nem substituídas pela correcção dos filtros. Diagnóstico da ligação Edge continua separado; leitura de URL não declarada recuperada. Nenhum processo local foi terminado nem porta libertada; InvesteMoz e SGF preservados. Para o registo da equipa: no cluster autorizado, editar conteúdo legítimo → Guardar rascunho no servidor → sair e reabrir → comparar campos/versão recuperados → conferir na ficha pública exclusivamente a versão aprovada. Registar utilizador/âmbito autorizado, URL/cluster, versão instalada, versões guardada/aprovada, resultado e divergências. Uma nova gravação não deve substituir a aprovação pública existente; nova aprovação depende da decisão de conteúdo aplicável. Sem exemplar aprovado disponível, manter essa parte da conferência pendente em vez de publicar dados sintéticos. + +### 29/10/2026 — correcções e validação automática local após reconciliação + +Validação técnica local concluída na branch `codex/investemoz-reconciliation-20261029`, a partir de `63147312`. Cobertura conferida contra o inventário completo: **798 testes PHP** (91 Unit, 706 Feature em 216 classes e 1 ensaio de schema com 3106 asserções) e **1190 testes frontend**. Tipos TypeScript, PHPStan nível 7 sem erros, Pint, lint/formatação, compilação e os controlos positivos/negativos C132 passaram. Resultados de repetições consolidados por identificador de teste, sem duplicar contagens e sem novos skips. + +Correcções incluem contratos de dados e falhas explícitas de leitura/hash, continuidade do contexto do dossier no simulador, invalidação do cache actual da página inicial, leitura de originais antes do carregamento do catálogo opcional e directório mínimo de contas na administração autorizada. A classificação territorial reutiliza directamente o âmbito público existente, eliminando a subconsulta recursiva redundante; a varredura de rotas com o catálogo territorial completo passou em cerca de 35 segundos, com limite QA de 20 segundos por consulta. Mantidos os testes de exclusão de referências privadas/não aprovadas. Fixtures alinhadas com autenticação, MFA recente, publicação explícita e separação dos snapshots pessoal/económico. O fake HTTP de assets também isola manifestos de fontes mutáveis. + +Execução exclusivamente em bases QA isoladas em `127.0.0.1:55441`, com contextos vazios ou clones preparados conforme as guardas originais. Os 28 originais editoriais foram recuperados de arquivo local e conferidos por SHA-256; seis testes editoriais passaram com scanner real. Preservados os originais, arquivos de fontes, ferramentas históricas, chave da aplicação, dados activos e históricos. Zero migrações/importações/escritas na base activa, push ou deployment. A alteração inclui formatação abrangente do código mantido; o manifesto de hashes identifica os ficheiros validados. + +**Conferência visual autenticada pendente:** a revisão automática interrompeu Computer Use por não conseguir determinar com confiança o URL actual do Chrome. Não foi declarado aceite visual de Competitividade nem encerramento transversal de ACT-01, ACT-02 ou V2-07/V2-10. Recibo e provas: [local-validation-20261029.json](local-validation-20261029.json). diff --git a/lang/pt/auth.php b/lang/pt/auth.php index 483257bf..e4083583 100644 --- a/lang/pt/auth.php +++ b/lang/pt/auth.php @@ -1,4 +1,5 @@ 'O email ou a palavra-passe não correspondem aos nossos registos.', 'password' => 'A palavra-passe está incorrecta.', diff --git a/lang/pt/validation.php b/lang/pt/validation.php index efc95c29..c3abedae 100644 --- a/lang/pt/validation.php +++ b/lang/pt/validation.php @@ -1,4 +1,5 @@ 'O campo :attribute é obrigatório.', 'required_if' => 'O campo :attribute é obrigatório quando :other tem o valor :value.', diff --git a/pint.json b/pint.json index 93061b6b..d52bad27 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,4 @@ { - "preset": "laravel" + "preset": "laravel", + "exclude": ["outputs", "tools", "docs"] } diff --git a/resources/js/components/acquisition-allocation.tsx b/resources/js/components/acquisition-allocation.tsx index 7d7ad905..64b7a393 100644 --- a/resources/js/components/acquisition-allocation.tsx +++ b/resources/js/components/acquisition-allocation.tsx @@ -1,37 +1,341 @@ -import type {Acquisition} from '@/lib/iva-acquisitions'; -import {newAcquisitionBC} from '@/lib/iva-acquisition-bc'; -import {newAcquisitionAllocation,allocationReducedChoices,adoptAllocationReduced,acquisitionAllocationDecision,type AcquisitionAllocation} from '@/lib/iva-acquisition-allocation'; -export function AcquisitionAllocationEditor({draft,row,onChange}:{draft:Record;row:Acquisition;onChange:(patch:Partial)=>void}){ - const d=row.d; - if(!d)return ; - const update=(value:Partial)=>onChange({d:{...d,...value}}); - const fact=(key:string,label:string)=>; - const text=(key:'numerator'|'denominator'|'unit'|'referenceYear'|'scopeRef'|'source'|'communicationRef'|'declarationRef',label:string)=>; - const decision=acquisitionAllocationDecision(draft,row),choices=d.method==='reduced'?allocationReducedChoices(draft):[]; - return
Afectação parcial e proporções — D -

Reutiliza documento,montantes,moeda,período e condiçõesB/C. Escolher afectação real/pro rata identifica esta aquisição como mista;18(6),como exclusiva ao âmbito de origem. O vínculo individual deA permanece conservado,sem definir silenciosamente a afectação mista. A versão anterior não é recalculada.

- - {text('scopeRef','Âmbito da aquisição/actividade:entidade,operações,período e critério aplicáveis')} - {text('source','Fonte/documento ou pressuposto expresso das bases e do método')} - {fact('scopeConfirmed','Esta aquisição corresponde ao âmbito e à fonte identificados;sem usar apenas o sector ou a oportunidade')} - - {d.method==='reduced'?<> - {!choices.length&&

Não há base reduzida registada nesta oportunidade. Prepare a origem no percurso existente;sem percentagem por defeito.

} - {!!d.reducedId&&} -

Reutiliza62%,85%,40% ou75% segundo a origem demonstrada. São proporções de dedução18(6),não taxasIVA. Não volta a reduzir o montante de aquisição nem duplica o desconto da base.

- :d.method&&<>{fact('rightBasis','A parte identificada dá direito à dedução18/19,com fundamento na fonte indicada;isenção não implica automaticamente direito')} - {text('numerator',d.method==='actual'?'Parte efectivamente afecta a operações com direito — numerador':'Montante anual semIVA das operações com direito18/19 — numerador')} - {text('denominator',d.method==='actual'?'Utilização total no mesmo critério/unidade — denominador':'Montante anual semIVA de todas as operações,incluindo fora de campo — denominador')} - {d.method==='actual'?<>{text('unit','Unidade e critério de afectação real (mesma base em ambas as parcelas)')}{fact('actualScope','Repartição corresponde à afectação real desta aquisição/parte,documentada no critério identificado')}{fact('communication','Comunicação prévia à Direcção Geral de Impostos22(2),conforme a fonte — não é nova aprovação do portal')}{text('communicationRef','Referência da comunicação prévia22(2)')}{fact('conditionsMet','Condições especiais concretamente aplicáveis ao método,quando existentes,estão reflectidas na fonte e cumpridas')}:<> - - {text('referenceYear','Ano das bases anuais utilizadas')} - {fact('annualScope','Totais cobrem o âmbito anual do sujeito passivo identificado,sem substituir pelo mês ou apenas por uma oportunidade')} - {fact('netExclusions','Bases semIVA22(4–5):incluem operações fora de campo;excluem vendas de activo usado e imobiliárias/financeiras acessórias')} - {d.basis==='initial_changed'&&<>{fact('initialChanged','Há início de actividade ou alteração substancial que fundamenta a estimativa22(7)')}{text('declarationRef','Referência da declaração de início/alterações que contém a estimativa')}} - -

O quociente é arredondado à centésima imediatamente superior22(8). Mantêm-se o quociente original e a proporção aplicada;fracções monetárias continuam exactas. O acerto anual22(6) segueE,sem alterar versões anteriores.

- }} -

{decision.reason}

- -
; +import type { Acquisition } from '@/lib/iva-acquisitions'; +import { newAcquisitionBC } from '@/lib/iva-acquisition-bc'; +import { + newAcquisitionAllocation, + allocationReducedChoices, + adoptAllocationReduced, + acquisitionAllocationDecision, + type AcquisitionAllocation, +} from '@/lib/iva-acquisition-allocation'; +export function AcquisitionAllocationEditor({ + draft, + row, + onChange, +}: { + draft: Record; + row: Acquisition; + onChange: (patch: Partial) => void; +}) { + const d = row.d; + if (!d) + return ( + + ); + const update = (value: Partial) => + onChange({ d: { ...d, ...value } }); + const fact = (key: string, label: string) => ( + + ); + const text = ( + key: + | 'numerator' + | 'denominator' + | 'unit' + | 'referenceYear' + | 'scopeRef' + | 'source' + | 'communicationRef' + | 'declarationRef', + label: string, + ) => ( + + ); + const decision = acquisitionAllocationDecision(draft, row), + choices = d.method === 'reduced' ? allocationReducedChoices(draft) : []; + return ( +
+ Afectação parcial e proporções — D +

+ Reutiliza documento,montantes,moeda,período e condiçõesB/C. + Escolher afectação real/pro rata identifica esta aquisição como + mista;18(6),como exclusiva ao âmbito de origem. O vínculo + individual deA permanece conservado,sem definir silenciosamente + a afectação mista. A versão anterior não é recalculada. +

+ + {text( + 'scopeRef', + 'Âmbito da aquisição/actividade:entidade,operações,período e critério aplicáveis', + )} + {text( + 'source', + 'Fonte/documento ou pressuposto expresso das bases e do método', + )} + {fact( + 'scopeConfirmed', + 'Esta aquisição corresponde ao âmbito e à fonte identificados;sem usar apenas o sector ou a oportunidade', + )} + + {d.method === 'reduced' ? ( + <> + + {!choices.length && ( +

+ Não há base reduzida registada nesta oportunidade. + Prepare a origem no percurso existente;sem + percentagem por defeito. +

+ )} + {!!d.reducedId && ( + + )} +

+ Reutiliza62%,85%,40% ou75% segundo a origem demonstrada. + São proporções de dedução18(6),não taxasIVA. Não volta a + reduzir o montante de aquisição nem duplica o desconto + da base. +

+ + ) : ( + d.method && ( + <> + {fact( + 'rightBasis', + 'A parte identificada dá direito à dedução18/19,com fundamento na fonte indicada;isenção não implica automaticamente direito', + )} + {text( + 'numerator', + d.method === 'actual' + ? 'Parte efectivamente afecta a operações com direito — numerador' + : 'Montante anual semIVA das operações com direito18/19 — numerador', + )} + {text( + 'denominator', + d.method === 'actual' + ? 'Utilização total no mesmo critério/unidade — denominador' + : 'Montante anual semIVA de todas as operações,incluindo fora de campo — denominador', + )} + {d.method === 'actual' ? ( + <> + {text( + 'unit', + 'Unidade e critério de afectação real (mesma base em ambas as parcelas)', + )} + {fact( + 'actualScope', + 'Repartição corresponde à afectação real desta aquisição/parte,documentada no critério identificado', + )} + {fact( + 'communication', + 'Comunicação prévia à Direcção Geral de Impostos22(2),conforme a fonte — não é nova aprovação do portal', + )} + {text( + 'communicationRef', + 'Referência da comunicação prévia22(2)', + )} + {fact( + 'conditionsMet', + 'Condições especiais concretamente aplicáveis ao método,quando existentes,estão reflectidas na fonte e cumpridas', + )} + + ) : ( + <> + + {text( + 'referenceYear', + 'Ano das bases anuais utilizadas', + )} + {fact( + 'annualScope', + 'Totais cobrem o âmbito anual do sujeito passivo identificado,sem substituir pelo mês ou apenas por uma oportunidade', + )} + {fact( + 'netExclusions', + 'Bases semIVA22(4–5):incluem operações fora de campo;excluem vendas de activo usado e imobiliárias/financeiras acessórias', + )} + {d.basis === 'initial_changed' && ( + <> + {fact( + 'initialChanged', + 'Há início de actividade ou alteração substancial que fundamenta a estimativa22(7)', + )} + {text( + 'declarationRef', + 'Referência da declaração de início/alterações que contém a estimativa', + )} + + )} + +

+ O quociente é arredondado à centésima + imediatamente superior22(8). Mantêm-se o + quociente original e a proporção + aplicada;fracções monetárias continuam + exactas. O acerto anual22(6) segueE,sem + alterar versões anteriores. +

+ + )} + + ) + )} +

{decision.reason}

+ +
+ ); } diff --git a/resources/js/components/acquisition-beverage-restitution.tsx b/resources/js/components/acquisition-beverage-restitution.tsx index 6fc490d2..628f2189 100644 --- a/resources/js/components/acquisition-beverage-restitution.tsx +++ b/resources/js/components/acquisition-beverage-restitution.tsx @@ -1,29 +1,340 @@ -import {useState} from 'react'; -import {beverageRestitutionBook,beverageRestitutionSources,newBeverageRestitution,saveBeverageRestitutionBook,recordBeverageRestitution,beverageRestitutionReportRows,type BeverageRestitutionBook,type BeverageRestitutionInput} from '@/lib/iva-beverage-restitution'; -import {beverageDeductionEvidence} from '@/lib/iva-beverage-allocation'; -import {fractionPresentation} from '@/lib/fiscal-rational'; -import {FiscalClaimEventsFields} from './fiscal-claim-events'; -export function AcquisitionBeverageRestitution({draft,onChange}:{draft:Record;onChange:(field:string,value:string)=>void}){ - const[error,setError]=useState('');let b:BeverageRestitutionBook;try{b=beverageRestitutionBook(draft);}catch(e){return

{String(e)}

;} - const save=(next:BeverageRestitutionBook)=>{try{onChange('modelo_a_snapshot',saveBeverageRestitutionBook(draft,next));setError('');}catch(e){setError(String(e));}},sources=beverageRestitutionSources(draft); - const change=(id:string,p:Partial)=>save({...b,rows:b.rows.map(r=>r.id===id?{...r,...p}:r)}); - return
L3-E — restituição de bebidas13(2) -

Reutiliza bebidas de abastecimento de bordo13(1)(c/d/h),com documento e vínculoL1/L2. Escolha restituição na aquisição e recalculeL2:esta linha não gera dedução nem crédito para reembolso geral. Não se aplica a qualquer despesa com bebidas,tabaco ou recepção.

-

Âmbito2026/MZN:linha integral ou parcelas de IVA documentadas,sem sobreposição. A parte não pedida fica sem destino presumido. Uma dedução efectiva anterior conserva montante e histórico;identifique a parcela já deduzida. A mudança ou anulação dessa dedução requer regularização com fundamento próprio,sem a libertar por simples edição. Pedido,decisão e recebimento são registos distintos. Procedimento/formulário específico e prazos não automatizados;submissão fora doInvesteMoz,sem condições importadas do reembolso geralD.

- {error&&

{error}

} - - {b.rows.map(r=>
Restituição — {r.source?.period||'origem por escolher'} - - {r.source&&

Documento:{r.source.documentRef};{r.source.documentDate};fornecedor{r.source.supplier};operaçãoL1{r.source.operationId};{r.source.operationRef}. O total documental é reutilizado sem taxa presumida; repartição e IVA do fornecimento mantêm cálculos próprios.

} - - - - {r.allocation&&
Repartição do IVA documental em MZN

Os limites identificam partes do IVA da linha: por exemplo, de 0 a 6 e de 6 a 16 são parcelas distintas de um total de 16. A parte restante não gera dedução automática. Pode indicar valores decimais ou fracções exactas,como16/3;sem arredondar a dedução anterior.

{([['from','Início da parcela'],['to','Fim da parcela'],['reference','Suporte da repartição']] as const).map(([field,label])=>)}
} - {r.source&&(()=>{const evidence=beverageDeductionEvidence(draft,r.source!);return
Reconciliação da dedução anterior

IVA documental: {fractionPresentation(r.source!.vat).decimal} MZN. Dedução efectiva positiva identificada: {evidence.amount?fractionPresentation(evidence.amount).decimal:'Indeterminada'} MZN. {evidence.reason}

{evidence.records.map((v,i)=>

{v.context};revisão{v.revision};{v.calculatedAt};{fractionPresentation(v.amount).decimal} MZN;{v.documents.join(' | ')}

)}{r.allocation&&evidence.records.length>0&&([['deductionFrom','Início da parcela já deduzida'],['deductionTo','Fim da parcela já deduzida'],['deductionReference','Suporte da afectação da dedução anterior']] as const).map(([field,label])=>)}
;})()} - change(r.id,p)}/> - -
)} - {b.rows.length>0&&} - {beverageRestitutionReportRows(draft).map((r,i)=>

{r[1]}: {r[2]}
{r[3]}

)} -
; +import { inputText } from '../lib/input-text.ts'; +import { useState } from 'react'; +import { + beverageRestitutionBook, + beverageRestitutionSources, + newBeverageRestitution, + saveBeverageRestitutionBook, + recordBeverageRestitution, + beverageRestitutionReportRows, + type BeverageRestitutionBook, + type BeverageRestitutionInput, +} from '@/lib/iva-beverage-restitution'; +import { beverageDeductionEvidence } from '@/lib/iva-beverage-allocation'; +import { fractionPresentation } from '@/lib/fiscal-rational'; +import { FiscalClaimEventsFields } from './fiscal-claim-events'; +export function AcquisitionBeverageRestitution({ + draft, + onChange, +}: { + draft: Record; + onChange: (field: string, value: string) => void; +}) { + const [error, setError] = useState(''); + let b: BeverageRestitutionBook; + try { + b = beverageRestitutionBook(draft); + } catch (e) { + return

{String(e)}

; + } + const save = (next: BeverageRestitutionBook) => { + try { + onChange( + 'modelo_a_snapshot', + saveBeverageRestitutionBook(draft, next), + ); + setError(''); + } catch (e) { + setError(String(e)); + } + }, + sources = beverageRestitutionSources(draft); + const change = (id: string, p: Partial) => + save({ + ...b, + rows: b.rows.map((r) => (r.id === id ? { ...r, ...p } : r)), + }); + return ( +
+ L3-E — restituição de bebidas13(2) +

+ Reutiliza bebidas de abastecimento de bordo13(1)(c/d/h),com + documento e vínculoL1/L2. Escolha restituição na aquisição e + recalculeL2:esta linha não gera dedução nem crédito para + reembolso geral. Não se aplica a qualquer despesa com + bebidas,tabaco ou recepção. +

+

+ Âmbito2026/MZN:linha integral ou parcelas de IVA + documentadas,sem sobreposição. A parte não pedida fica sem + destino presumido. Uma dedução efectiva anterior conserva + montante e histórico;identifique a parcela já deduzida. A + mudança ou anulação dessa dedução requer regularização com + fundamento próprio,sem a libertar por simples edição. + Pedido,decisão e recebimento são registos distintos. + Procedimento/formulário específico e prazos não + automatizados;submissão fora doInvesteMoz,sem condições + importadas do reembolso geralD. +

+ {error &&

{error}

} + + {b.rows.map((r) => ( +
+ + Restituição —{' '} + {r.source?.period || 'origem por escolher'} + + + {r.source && ( +

+ Documento:{r.source.documentRef}; + {r.source.documentDate};fornecedor + {r.source.supplier};operaçãoL1{r.source.operationId} + ;{r.source.operationRef}. O total documental é + reutilizado sem taxa presumida; repartição e IVA do + fornecimento mantêm cálculos próprios. +

+ )} + + + + {r.allocation && ( +
+ Repartição do IVA documental em MZN +

+ Os limites identificam partes do IVA da linha: + por exemplo, de 0 a 6 e de 6 a 16 são parcelas + distintas de um total de 16. A parte restante + não gera dedução automática. Pode indicar + valores decimais ou fracções + exactas,como16/3;sem arredondar a dedução + anterior. +

+ {( + [ + ['from', 'Início da parcela'], + ['to', 'Fim da parcela'], + ['reference', 'Suporte da repartição'], + ] as const + ).map(([field, label]) => ( + + ))} +
+ )} + {r.source && + (() => { + const evidence = beverageDeductionEvidence( + draft, + r.source!, + ); + return ( +
+ + Reconciliação da dedução anterior + +

+ IVA documental:{' '} + { + fractionPresentation(r.source!.vat) + .decimal + }{' '} + MZN. Dedução efectiva positiva + identificada:{' '} + {evidence.amount + ? fractionPresentation( + evidence.amount, + ).decimal + : 'Indeterminada'}{' '} + MZN. {evidence.reason} +

+ {evidence.records.map((v, i) => ( +

+ {v.context};revisão{v.revision}; + {v.calculatedAt}; + { + fractionPresentation(v.amount) + .decimal + }{' '} + MZN;{v.documents.join(' | ')} +

+ ))} + {r.allocation && + evidence.records.length > 0 && + ( + [ + [ + 'deductionFrom', + 'Início da parcela já deduzida', + ], + [ + 'deductionTo', + 'Fim da parcela já deduzida', + ], + [ + 'deductionReference', + 'Suporte da afectação da dedução anterior', + ], + ] as const + ).map(([field, label]) => ( + + ))} +
+ ); + })()} + change(r.id, p)} + /> + +
+ ))} + {b.rows.length > 0 && ( + + )} + {beverageRestitutionReportRows(draft).map((r, i) => ( +

+ {r[1]}: {r[2]} +
+ {r[3]} +

+ ))} +
+ ); } diff --git a/resources/js/components/acquisition-closure.tsx b/resources/js/components/acquisition-closure.tsx index 47354521..b80e9d6c 100644 --- a/resources/js/components/acquisition-closure.tsx +++ b/resources/js/components/acquisition-closure.tsx @@ -1,48 +1,598 @@ -import {useState} from 'react'; -import {closureBook,closureSources,newClosure,newClosureCredit,saveClosureBook,recordClosure,adoptPreviousCredit,closureReportRows,type ClosureInput,type ClosureCredit,type ClosureBook} from '@/lib/iva-closure'; -export function AcquisitionClosure({draft,onChange}:{draft:Record;onChange:(field:string,value:string)=>void}){ - const[error,setError]=useState('');let b:ClosureBook;try{b=closureBook(draft);}catch(e){return

{String(e)}

;} - const save=(next:ClosureBook)=>{try{onChange('modelo_a_snapshot',saveClosureBook(draft,next));setError('');}catch(e){setError(String(e));}}; - const change=(id:string,p:Partial)=>save({...b,rows:b.rows.map(r=>r.id===id?{...r,...p}:r)}); - const sources=closureSources(draft); - const entities=[...new Set(b.rows.map(r=>r.entityRef.trim()).filter(Boolean))]; - const creditChange=(r:ClosureInput,id:string,p:Partial)=>change(r.id,{credits:r.credits.map(c=>c.id===id?{...c,...p}:c)}); - const choice=(r:ClosureInput,key:'firstDeclaration'|'creditsComplete',label:string)=>; - return
L3-A/B — crédito,fecho e pagamentos por período -

Reutiliza resultados congelados de L2. Crédito anterior,saldo calculado,pagamento registado e transporte são distintos. Identifique o âmbito fiscal da entidade,sem consolidar oportunidades implicitamente.

-

Primeira declaração em prazo,2026/MZN e reporte integral ou afectação de parcela ao pedidoD. SubstituiçõesC,acompanhamento de reembolsoD e restituição de bebidasE continuam próprios. A parcela preparada para pedido é separada do reporte,sem concessão automática. Prazo/data são declarados,não calculados pelo portal. Uma simulação não submete declarações nem efectua pagamentos.

- {error&&

{error}

} - - {b.rows.map(r=>
Fecho — {r.period||'período por escolher'} - - - - {choice(r,'firstDeclaration','É a primeira declaração deste período (ou pressuposto explícito)')} - - - -
Créditos de origem identificada
- {r.credits.map(c=>
{c.kind==='opening'?'19 — saldo de abertura':c.kind==='previous'?'19 — transporte anterior':'20 — crédito comunicado'} - {c.kind==='previous'?:<> - - - - {c.kind==='communication'&&}} - {c.kind==='previous'&&<>

Origem:{c.reference};{c.period};apresentação{c.amount}. Cálculo pela fracção exacta;origem alterada exige adopção expressa.

} -
)} - {(['opening','communication','previous'] as const).map(kind=>)} - {choice(r,'creditsComplete','Reconciliei todos os créditos;lista vazia confirma expressamente ausência')} - - {r.interest==='documented'&&} - {r.interest&&} - - {r.destination==='refund'&&} -
Pagamentos registados,distintos do saldo calculado
- {r.destination==='refund'&&} - {r.payments.map(p=>
{(['reference','date','amount'] as const).map((k,i)=>)}
)} - -
)} - - {closureReportRows(draft).map(([,label,value,source],i)=>

{label}: {value}{source}

)} -
; +import { inputText } from '../lib/input-text.ts'; +import { useState } from 'react'; +import { + closureBook, + closureSources, + newClosure, + newClosureCredit, + saveClosureBook, + recordClosure, + adoptPreviousCredit, + closureReportRows, + type ClosureInput, + type ClosureCredit, + type ClosureBook, +} from '@/lib/iva-closure'; +export function AcquisitionClosure({ + draft, + onChange, +}: { + draft: Record; + onChange: (field: string, value: string) => void; +}) { + const [error, setError] = useState(''); + let b: ClosureBook; + try { + b = closureBook(draft); + } catch (e) { + return

{String(e)}

; + } + const save = (next: ClosureBook) => { + try { + onChange('modelo_a_snapshot', saveClosureBook(draft, next)); + setError(''); + } catch (e) { + setError(String(e)); + } + }; + const change = (id: string, p: Partial) => + save({ + ...b, + rows: b.rows.map((r) => (r.id === id ? { ...r, ...p } : r)), + }); + const sources = closureSources(draft); + const entities = [ + ...new Set(b.rows.map((r) => r.entityRef.trim()).filter(Boolean)), + ]; + const creditChange = ( + r: ClosureInput, + id: string, + p: Partial, + ) => + change(r.id, { + credits: r.credits.map((c) => (c.id === id ? { ...c, ...p } : c)), + }); + const choice = ( + r: ClosureInput, + key: 'firstDeclaration' | 'creditsComplete', + label: string, + ) => ( + + ); + return ( +
+ L3-A/B — crédito,fecho e pagamentos por período +

+ Reutiliza resultados congelados de L2. Crédito anterior,saldo + calculado,pagamento registado e transporte são distintos. + Identifique o âmbito fiscal da entidade,sem consolidar + oportunidades implicitamente. +

+

+ Primeira declaração em prazo,2026/MZN e reporte integral ou + afectação de parcela ao pedidoD. SubstituiçõesC,acompanhamento + de reembolsoD e restituição de bebidasE continuam próprios. A + parcela preparada para pedido é separada do reporte,sem + concessão automática. Prazo/data são declarados,não calculados + pelo portal. Uma simulação não submete declarações nem efectua + pagamentos. +

+ {error &&

{error}

} + + {b.rows.map((r) => ( +
+ + Fecho — {r.period || 'período por escolher'} + + + + + {choice( + r, + 'firstDeclaration', + 'É a primeira declaração deste período (ou pressuposto explícito)', + )} + + + +
Créditos de origem identificada
+ {r.credits.map((c) => ( +
+ + {c.kind === 'opening' + ? '19 — saldo de abertura' + : c.kind === 'previous' + ? '19 — transporte anterior' + : '20 — crédito comunicado'} + + {c.kind === 'previous' ? ( + + ) : ( + <> + + + + {c.kind === 'communication' && ( + + )} + + )} + {c.kind === 'previous' && ( + <> +

+ Origem:{c.reference};{c.period} + ;apresentação{c.amount}. Cálculo pela + fracção exacta;origem alterada exige + adopção expressa. +

+ + + )} + +
+ ))} + {(['opening', 'communication', 'previous'] as const).map( + (kind) => ( + + ), + )} + {choice( + r, + 'creditsComplete', + 'Reconciliei todos os créditos;lista vazia confirma expressamente ausência', + )} + + {r.interest === 'documented' && ( + + )} + {r.interest && ( + + )} + + {r.destination === 'refund' && ( + + )} +
Pagamentos registados,distintos do saldo calculado
+ {r.destination === 'refund' && ( + + )} + {r.payments.map((p) => ( +
+ {(['reference', 'date', 'amount'] as const).map( + (k, i) => ( + + ), + )} + +
+ ))} + + +
+ ))} + + {closureReportRows(draft).map(([, label, value, source], i) => ( +

+ {label}: {value} + {source} +

+ ))} +
+ ); } diff --git a/resources/js/components/acquisition-imports-exclusions.tsx b/resources/js/components/acquisition-imports-exclusions.tsx index fd040002..c48fe427 100644 --- a/resources/js/components/acquisition-imports-exclusions.tsx +++ b/resources/js/components/acquisition-imports-exclusions.tsx @@ -1,29 +1,379 @@ -import type {Acquisition} from '@/lib/iva-acquisitions'; -import {acquisitionImportSources,expenseOptions,newAcquisitionBC,reuseAcquisitionImport,type AcquisitionBC} from '@/lib/iva-acquisition-bc'; -export function AcquisitionImportsExclusions({draft,row,onChange}:{draft:Record;row:Acquisition;onChange:(patch:Partial)=>void}){ - const b=row.bc;const update=(patch:Partial)=>onChange({bc:{...(b??newAcquisitionBC()),...patch}}); - const choice=(key:'reducedSupply'|'belongs'|'ownImportVat',label:string)=>; - const fact=(key:string,label:string)=>; - if(!b)return
; - const imported=row.domestic==='no';const sources=acquisitionImportSources(draft); - return
Importações e exclusões — B/C -

Dados existentes conservados;as respostas são factos declarados ou pressupostos de projecção. Informação em falta fica indeterminada. Alterar a natureza da despesa conserva respostas anteriores sem as aplicar a outro tratamento.

- {imported&&<>

Campo11:IVA devido na importação de bens pelo documento aduaneiro. Não calcula direitosL13,não copia o valor aduaneiro como IVA e não exige fornecedor estrangeiro inscrito no IVA interno.

- - {b.source&&

Origem conservada:{b.source.map}/{b.source.id}. Reutilizar novamente adopta a origem actual,sem substituir edições.

} - {(['importer','goodsRef','vatRef'] as const).map((key,i)=>)} - {choice('belongs','Bilhete na titularidade do sujeito passivo/importador desta aquisição')} - {fact('exigibleInPeriod','IVA aduaneiro tornou-se exigível no período de dedução indicado,conforme a fonte/documento21(1);não basta a data de emissão do bilhete')} - {choice('ownImportVat','Montante é apenas IVA próprio devido nesta importação,sem somar novamente IVA de serviço incluído na base dos bens')} - -

IVA isento/zero não é montante desconhecido. Para ausência de imposto a deduzir,indique zero expressamente no IVA;isençãoL1 não preenche esse montante.

} - {!imported&&choice('reducedSupply','O IVA suportado respeita a fornecimento abrangido pelo artigo17-A (20(2))')} - - {b.expense==='vehicle'&&<>{fact('tourism','Bem abrangido20(1)(a):viatura de turismo,barco de recreio,helicóptero,avião,moto/motociclo. Viatura:inclui reboque;não destinada unicamente a mercadorias/uso agrícola,comercial ou industrial,ou mista/passageiros até9lugares incluindo condutor')}{b.facts.tourism==='yes'&&fact('saleOrExploit','Venda ou exploração deste bem constitui objecto da actividade20(3)(a)')}} - {b.expense==='fuel'&&<>{fact('resale','Combustível adquirido para revenda,com actividade e destino identificados20(3)(a)')}{b.facts.resale==='no'&&<>{b.fuelKind==='diesel'&&<>{b.dieselUse&&b.dieselUse!=='other'&&fact('dieselScope','O uso e as características correspondem integralmente à categoria20(1)(b)(i–iv) seleccionada')}}}} - {b.expense==='lodging_food'&&fact('commercialTraveller','Alojamento/alimentação de viajante comercial,agindo por conta própria,no quadro da actividade profissional20(3)(b)')} - {b.expense==='telephone'&&fact('ownLandline','Serviço de telefone fixo em nome do sujeito passivo20(1)(e)')} - {b.expense==='beverages'&&<>{fact('beverageSpecial','Bebidas de abastecimento de bordo nas alíneas13(1)(c/d/h),com origemL1 identificada;tabaco e recepção não estão abrangidos por esta excepção')}{b.facts.beverageSpecial==='yes'&&<>{b.beveragePath==='deduction'&&fact('onlyDeduction','Parcela identificada exclusivamente para dedução;sem pedido/restituição da mesma parcela')}{fact('notCoastalFishing','Se origem13(1)(d):embarcação de salvamento/assistência marítima,sem ser pesca costeira cujas provisões ficam excluídas')}}} - -
; +import type { Acquisition } from '@/lib/iva-acquisitions'; +import { + acquisitionImportSources, + expenseOptions, + newAcquisitionBC, + reuseAcquisitionImport, + type AcquisitionBC, +} from '@/lib/iva-acquisition-bc'; +export function AcquisitionImportsExclusions({ + draft, + row, + onChange, +}: { + draft: Record; + row: Acquisition; + onChange: (patch: Partial) => void; +}) { + const b = row.bc; + const update = (patch: Partial) => + onChange({ bc: { ...(b ?? newAcquisitionBC()), ...patch } }); + const choice = ( + key: 'reducedSupply' | 'belongs' | 'ownImportVat', + label: string, + ) => ( + + ); + const fact = (key: string, label: string) => ( + + ); + if (!b) + return ( +
+ + +
+ ); + const imported = row.domestic === 'no'; + const sources = acquisitionImportSources(draft); + return ( +
+ Importações e exclusões — B/C +

+ Dados existentes conservados;as respostas são factos declarados + ou pressupostos de projecção. Informação em falta fica + indeterminada. Alterar a natureza da despesa conserva respostas + anteriores sem as aplicar a outro tratamento. +

+ {imported && ( + <> +

+ Campo11:IVA devido na importação de bens pelo documento + aduaneiro. Não calcula direitosL13,não copia o valor + aduaneiro como IVA e não exige fornecedor estrangeiro + inscrito no IVA interno. +

+ + {b.source && ( +

+ Origem conservada:{b.source.map}/{b.source.id}. + Reutilizar novamente adopta a origem actual,sem + substituir edições. +

+ )} + {(['importer', 'goodsRef', 'vatRef'] as const).map( + (key, i) => ( + + ), + )} + {choice( + 'belongs', + 'Bilhete na titularidade do sujeito passivo/importador desta aquisição', + )} + {fact( + 'exigibleInPeriod', + 'IVA aduaneiro tornou-se exigível no período de dedução indicado,conforme a fonte/documento21(1);não basta a data de emissão do bilhete', + )} + {choice( + 'ownImportVat', + 'Montante é apenas IVA próprio devido nesta importação,sem somar novamente IVA de serviço incluído na base dos bens', + )} + +

+ IVA isento/zero não é montante desconhecido. Para + ausência de imposto a deduzir,indique zero expressamente + no IVA;isençãoL1 não preenche esse montante. +

+ + )} + {!imported && + choice( + 'reducedSupply', + 'O IVA suportado respeita a fornecimento abrangido pelo artigo17-A (20(2))', + )} + + {b.expense === 'vehicle' && ( + <> + {fact( + 'tourism', + 'Bem abrangido20(1)(a):viatura de turismo,barco de recreio,helicóptero,avião,moto/motociclo. Viatura:inclui reboque;não destinada unicamente a mercadorias/uso agrícola,comercial ou industrial,ou mista/passageiros até9lugares incluindo condutor', + )} + {b.facts.tourism === 'yes' && + fact( + 'saleOrExploit', + 'Venda ou exploração deste bem constitui objecto da actividade20(3)(a)', + )} + + )} + {b.expense === 'fuel' && ( + <> + {fact( + 'resale', + 'Combustível adquirido para revenda,com actividade e destino identificados20(3)(a)', + )} + {b.facts.resale === 'no' && ( + <> + + {b.fuelKind === 'diesel' && ( + <> + + {b.dieselUse && + b.dieselUse !== 'other' && + fact( + 'dieselScope', + 'O uso e as características correspondem integralmente à categoria20(1)(b)(i–iv) seleccionada', + )} + + )} + + )} + + )} + {b.expense === 'lodging_food' && + fact( + 'commercialTraveller', + 'Alojamento/alimentação de viajante comercial,agindo por conta própria,no quadro da actividade profissional20(3)(b)', + )} + {b.expense === 'telephone' && + fact( + 'ownLandline', + 'Serviço de telefone fixo em nome do sujeito passivo20(1)(e)', + )} + {b.expense === 'beverages' && ( + <> + {fact( + 'beverageSpecial', + 'Bebidas de abastecimento de bordo nas alíneas13(1)(c/d/h),com origemL1 identificada;tabaco e recepção não estão abrangidos por esta excepção', + )} + {b.facts.beverageSpecial === 'yes' && ( + <> + + {b.beveragePath === 'deduction' && + fact( + 'onlyDeduction', + 'Parcela identificada exclusivamente para dedução;sem pedido/restituição da mesma parcela', + )} + {fact( + 'notCoastalFishing', + 'Se origem13(1)(d):embarcação de salvamento/assistência marítima,sem ser pesca costeira cujas provisões ficam excluídas', + )} + + )} + + )} + +
+ ); } diff --git a/resources/js/components/acquisition-operation-link.tsx b/resources/js/components/acquisition-operation-link.tsx index ee0e5adf..82e30fc7 100644 --- a/resources/js/components/acquisition-operation-link.tsx +++ b/resources/js/components/acquisition-operation-link.tsx @@ -1,20 +1,143 @@ -import {acquisitionOperationChoices,createAcquisitionRightLink,linkedOperationRight,linkedBeverageDeductionRight,type AcquisitionRightLink} from '@/lib/iva-acquisition-right'; -import type {Acquisition} from '@/lib/iva-acquisitions'; +import { + acquisitionOperationChoices, + createAcquisitionRightLink, + linkedOperationRight, + linkedBeverageDeductionRight, + type AcquisitionRightLink, +} from '@/lib/iva-acquisition-right'; +import type { Acquisition } from '@/lib/iva-acquisitions'; -export function AcquisitionOperationLink({draft,row,onChange}:{draft:Record;row:Acquisition;onChange:(patch:Partial)=>void}){ - const choices=acquisitionOperationChoices(draft),link=row.deductionLink; - const adopt=(id:string)=>onChange({deductionLink:createAcquisitionRightLink(draft,id)}); - const patch=(value:Partial)=>{if(link)onChange({deductionLink:{...link,...value}});}; - const result=link?(row.bc?.expense==='beverages'&&row.bc.facts.beverageSpecial==='yes'&&['deduction','refund'].includes(row.bc.beveragePath)?linkedBeverageDeductionRight(draft,link,row.bc.facts.notCoastalFishing==='yes'):linkedOperationRight(draft,link)):null; - return
Aquisição → direito à dedução — L2-A -

Reutilize a classificação existente. A ligação conserva montantes, documento e escolhas da aquisição; não cria outra compra. Importações, despesas especiais e afectação parcial conservam enquadramento próprio.

- {choices.length===1&&!link?:} - {!choices.length&&

Não há operação classificada nesta oportunidade. A compra e o histórico permanecem disponíveis.

} - {link&&<>

{row.bc?.beveragePath==='refund'&&result?.status==='eligible'?'Condições da operação13(2) identificadas;via de restituição escolhida,sem dedução desta parcela.':result?.reason}

{result?.source} - - - {result?.status==='eligible'&&} - {result?.status==='indeterminate'&&choices.some(c=>c.id===link.operationId)&&} - } -
; +export function AcquisitionOperationLink({ + draft, + row, + onChange, +}: { + draft: Record; + row: Acquisition; + onChange: (patch: Partial) => void; +}) { + const choices = acquisitionOperationChoices(draft), + link = row.deductionLink; + const adopt = (id: string) => + onChange({ deductionLink: createAcquisitionRightLink(draft, id) }); + const patch = (value: Partial) => { + if (link) onChange({ deductionLink: { ...link, ...value } }); + }; + const result = link + ? row.bc?.expense === 'beverages' && + row.bc.facts.beverageSpecial === 'yes' && + ['deduction', 'refund'].includes(row.bc.beveragePath) + ? linkedBeverageDeductionRight( + draft, + link, + row.bc.facts.notCoastalFishing === 'yes', + ) + : linkedOperationRight(draft, link) + : null; + return ( +
+ Aquisição → direito à dedução — L2-A +

+ Reutilize a classificação existente. A ligação conserva + montantes, documento e escolhas da aquisição; não cria outra + compra. Importações, despesas especiais e afectação parcial + conservam enquadramento próprio. +

+ {choices.length === 1 && !link ? ( + + ) : ( + + )} + {!choices.length && ( +

+ Não há operação classificada nesta oportunidade. A compra e + o histórico permanecem disponíveis. +

+ )} + {link && ( + <> +

+ {row.bc?.beveragePath === 'refund' && + result?.status === 'eligible' + ? 'Condições da operação13(2) identificadas;via de restituição escolhida,sem dedução desta parcela.' + : result?.reason} +

+ {result?.source} + + + {result?.status === 'eligible' && ( + + )} + {result?.status === 'indeterminate' && + choices.some((c) => c.id === link.operationId) && ( + + )} + + )} +
+ ); } diff --git a/resources/js/components/acquisition-refund.tsx b/resources/js/components/acquisition-refund.tsx index 5ef93690..1826fd27 100644 --- a/resources/js/components/acquisition-refund.tsx +++ b/resources/js/components/acquisition-refund.tsx @@ -1,34 +1,416 @@ -import {useState} from 'react'; -import {refundBook,refundSources,newRefund,saveRefundBook,recordRefund,refundReportRows,type RefundBook,type RefundInput} from '@/lib/iva-refund'; -export function AcquisitionRefund({draft,onChange}:{draft:Record;onChange:(field:string,value:string)=>void}){ - const[error,setError]=useState('');let b:RefundBook;try{b=refundBook(draft);}catch(e){return

{String(e)}

;} - const save=(book:RefundBook)=>{try{onChange('modelo_a_snapshot',saveRefundBook(draft,book));setError('');}catch(e){setError(String(e));}}; - const change=(id:string,p:Partial)=>save({...b,rows:b.rows.map(r=>r.id===id?{...r,...p}:r)}),sources=refundSources(draft); - const choice=(r:RefundInput,k:'factsConfirmed'|'oldestFirst',label:string)=>; - return
L3-D — pedido,decisão e recebimento do reembolso -

Primeiro,no fecho do período,escolha a parcela destinada ao pedido27 e calcule a afectação:25=26+27. Apenas26 continua em reporte. Reutilize a afectação abaixo;crédito já transportado não é uma nova parcela disponível.

-

Preparar ou apresentar o pedido não é deferimento;deferimento não é recebimento. Submissão,formulários regulamentares,suspensões e pagamentos bancários ocorrem fora doInvesteMoz. O prazo legal não é previsão de caixa. Não se aplica a antiga obrigação50%/12meses,revogada em2026.

- {error&&

{error}

} - - {b.rows.map(r=>
Pedido de reembolso — {r.source?.period||'período por escolher'} - - - - - {r.ground==='cessation'&&} -