fix: complete InvesteMoz validation and error corrections

This commit is contained in:
2026-10-29 21:47:33 +02:00
parent 63147312e2
commit daf8623441
718 changed files with 127308 additions and 20959 deletions
+9 -4
View File
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -53,7 +54,9 @@ class ImportDocumentBatch extends Command
]; ];
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8'; private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa'; private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
private const TRIO_HASHES = [ private const TRIO_HASHES = [
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941', 'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5', 'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
@@ -276,7 +279,9 @@ class ImportDocumentBatch extends Command
return $failures > 0 ? self::FAILURE : self::SUCCESS; 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<array-key, mixed> $manifest
*/
private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void 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); $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.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']); ->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) 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.'); 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 || ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|| $verified === false || $verified > time() || $verified < time() - 86400 || $verified === false || $verified > time() || $verified < time() - 86400
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '') || ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|| ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path'])) || ! hash_equals($proof['sha256'] ?? '', CheckedFile::sha256($proof['dump_path']))
|| ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_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.'); throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.');
} }
} }
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Services\SimulationTaxParameterResolver; use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression; use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance; use App\Services\SimulationTaxRuleGovernance;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase; use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -24,8 +25,8 @@ class RegisterFiscalQaDraft extends Command
$source = InvestmentLegalSource::findOrFail($this->argument('source')); $source = InvestmentLegalSource::findOrFail($this->argument('source'));
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022' 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).'); && $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); $payload = json_decode(CheckedFile::read(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')); $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.'); 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; $payload['legal_source_id'] = (string) $source->id;
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']); app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
@@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -21,7 +21,7 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt'; $locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () { $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') $featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true) ->published()->where('is_featured', true)
@@ -8,11 +8,13 @@ use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived; use App\Jobs\NotifyInterestReceived;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\InterestRequest; use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\IdempotencyConflict; use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission; use App\Services\InterestSubmission;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -26,7 +28,7 @@ class InterestRequestController extends Controller
$validated = [...$request->validated(), '_actor_id' => $request->user()->id]; $validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key'); $key = $request->header('Idempotency-Key');
abort_unless(is_string($key), 422, 'Identificador do envio em falta.'); 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 { try {
$interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) { $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, 'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1, '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'], 'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
'country' => $validated['country'], 'existing_business_id' => $business?->id, 'country' => $validated['country'], 'existing_business_id' => $business?->id,
'modality' => $validated['modality'] ?? null], '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, 'submission_id' => $row->id, 'actor_id' => $request->user()->id,
'action' => 'submitted', 'version' => 1, 'created_at' => now(), 'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]); ]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save(); $interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest; return $interest;
}); });
@@ -99,7 +102,8 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.', 'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [ 'data' => [
'reference' => $interestRequest->reference, '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', 'tracking_url' => '/investidor/manifestacoes',
], ],
], 201); ], 201);
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\EconomicReferenceCatalog;
use App\Services\SectorOrganization;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -44,7 +47,9 @@ class ReferenceCatalogController extends Controller
$cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae'; $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'); $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'])) { if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']); $versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else { } else {
@@ -82,7 +87,7 @@ class ReferenceCatalogController extends Controller
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString(); $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(); $items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
if ($cae) { 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']); $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('valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', 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']); ->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) { if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']); $query->where('parent_id', $data['parent_id']);
} else { } else {
$query->whereNull('parent_id'); $query->whereNull('parent_id');
} }
if (! empty($data['sector_id'])) { if (! empty($data['sector_id'])) {
$sector = \App\Models\Sector::find($data['sector_id']); $sector = Sector::whereKey($data['sector_id'])->first();
$query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
if (! empty($data['q'])) { if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']); $this->search($query, $data['q'], ['code', 'label_pt_mz']);
@@ -4,13 +4,14 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Sector; use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class SectorController extends Controller class SectorController extends Controller
{ {
public function index(): JsonResponse 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, 'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug,
'shortDescription' => $s->short_description, 'description' => $s->description, 'shortDescription' => $s->short_description, 'description' => $s->description,
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count, 'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
@@ -25,7 +26,8 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()]) $sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail(); ->where('slug', $slug)->firstOrFail();
$sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get()); $sector->setRelation('opportunities', 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]);
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
} }
} }
@@ -5,14 +5,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts; use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SimulationOfferDocumentController extends Controller 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); abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request); $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']]); $request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file'); $file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath()); $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(); $existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) { if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store'); 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; throw $e;
} }
ScanDocumentForMalware::dispatch($documentId)->afterCommit(); 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'); 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); abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request); $drafts->find($id, $request);
$documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get(); $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'); 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); abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$drafts->find($id, $request); $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(); $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.'); 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)); $root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key)); $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($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']); 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), 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]; 'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id];
@@ -7,9 +7,11 @@ use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion; use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
final class CorridorAgribusinessController extends Controller 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)); 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); $access = app(PortalProfileAccess::class);
abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403); 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'); $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), return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'), 'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
'entries' => array_map(function ($entry) use ($records) { 'entries' => array_map(function ($entry) use ($records) {
$r = $records->get($entry['key']); $r = $records->get($entry['key']);
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null]; return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
}, $service->entries())]); }, $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']); $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.'); abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.');
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
$grant = $access->internalAssignment($request->user(), 'investment_project'); $grant = $access->internalAssignment($request->user(), 'investment_project');
abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.'); 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)]); 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 = []; $previews = [];
foreach ($service->entries() as $entry) { 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, $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)])]; [...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])];
} }
return response()->json(['previews' => $previews]); 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', $data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct', 'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct',
'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|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(); $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.'); 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) { return response()->json(DB::transaction(function () use ($request, $data) {
$result = []; $result = [];
foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'], foreach ($data['previews'] as $preview) {
[...$preview, 'confirm' => true, 'reason' => $data['reason']]); $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
}
return ['records' => $result]; return ['records' => $result];
})); }));
} }
@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Services\DocumentManagementAccess;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -12,7 +13,7 @@ class DocumentMalwareController extends Controller
public function retry(Request $request, string $id): RedirectResponse public function retry(Request $request, string $id): RedirectResponse
{ {
DB::transaction(function () use ($id, $request): void { 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($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422, 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.'); 'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
@@ -2,63 +2,79 @@
namespace App\Http\Controllers; 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\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Inertia\Inertia; 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 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()); $plan = $installation->preview($request->user());
return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store') 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()); $receipt = $installation->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store') 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); return $this->confirmMfa($request);
} }
public function confirmMfa(Request $request) public function confirmMfa(Request $request): Response
{ {
$actor = $request->user(); $actor = $request->user();
abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.'); 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'); return redirect()->route('editorial.installation');
} }
if ($request->isMethod('get')) { if ($request->isMethod('get')) {
return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store'); 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']); $data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']);
try { try {
$recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code'])); $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( $valid = $recovery !== null || (! empty($data['code']) && app(TwoFactorAuthenticationProvider::class)->verify(
\Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code'] Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
)); ));
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) { } catch (DecryptException $e) {
$valid = false; $valid = false;
} }
if (!$valid) { if (! $valid) {
event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor)); event(new TwoFactorAuthenticationFailed($actor));
throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']); throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']);
} }
if ($recovery !== null) { if ($recovery !== null) {
$actor->replaceRecoveryCode($recovery); $actor->replaceRecoveryCode($recovery);
} }
event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor)); event(new ValidTwoFactorAuthenticationCodeProvided($actor));
$request->session()->regenerate(); $request->session()->regenerate();
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store'); 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.'); 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') 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') ->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'); ->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(); $this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->find($id); $r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r, 404); abort_unless($r instanceof \stdClass, 404);
$reading = app(\App\Services\EditorialReading::class); $reading = app(EditorialReading::class);
$original = $reading->original($r); $original = $reading->original($r);
$content = $reading->content($r, $original); $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']), '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(); $this->installed();
$data = $this->validateContent($request); $data = $this->validateContent($request);
DB::transaction(function () use ($request, $id, $data) { DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); $r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r, 404); abort_unless($r !== null, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); 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.'); 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'])) { 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.'); 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); $content = $this->edition($r, $data);
$revision = (int) $r->metadata_version + 1; $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()]); 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(); $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('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])]); 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 redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.');
} }
/**
* @return array<array-key, mixed>
*/
private function validateContent(Request $request): 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']]); 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<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function edition(\stdClass $resource, array $data): array
{ {
$reading = app(\App\Services\EditorialReading::class); $reading = app(EditorialReading::class);
$original = $reading->original($resource); $original = $reading->original($resource);
$mode = $data['image_mode'] ?? (!empty($data['image_resource_id']) ? 'selected' : 'none'); $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.'); if ($mode === 'selected') {
return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int)$data['image_resource_id'] : null, 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'])]; '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(); $this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404); $resource = DB::table('portal_resources')->where('id', $id)->first();
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)]) abort_unless($resource instanceof \stdClass, 404);
->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
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(); $this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404); $resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$data = $this->validateContent($request); $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.'); 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)])
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'); ->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(); $this->installed();
$resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']); $resource = app(EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource, 404); abort_unless($resource !== null, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store');
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'));
} }
} }
@@ -11,6 +11,7 @@ use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake; use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects; use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\ExistingBusinessVerification; use App\Services\ExistingBusinessVerification;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\Mne\BusinessRepository; use App\Services\Mne\BusinessRepository;
@@ -67,7 +68,7 @@ class ExistingBusinessController extends Controller
$business = app(BusinessRepository::class)->find($id); $business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business); Gate::authorize('view', $business);
app(CompatibilityShadow::class)->schedule($id); 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), 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $business), 'canTriage' => $request->user()->can('triage', $business),
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business), 'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
@@ -3,25 +3,32 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\InvestmentComplements; use App\Services\InvestmentComplements;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class InvestmentComplementController extends Controller 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']); 'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
$items = []; $items = [];
$issues = []; $issues = [];
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) { foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
try { try {
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)]; $items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) { } catch (ModelNotFoundException|HttpException $e) {
$status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404; $status = $e instanceof HttpException ? $e->getStatusCode() : 404;
if (!isset($data['contexts']) || $status !== 404) throw $e; 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.']; $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'); return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store');
} }
} }
@@ -4,9 +4,14 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier; use App\Models\InvestmentDossier;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\OpportunityCorridorExplorer;
use App\Services\SectorInvestment;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; 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), 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), '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)), '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') : [], 'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]); 'territorial' => $this->territorial($filters)]);
@@ -89,7 +94,7 @@ class InvestmentDiscoveryController extends Controller
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]); $data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null; $dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) { 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) { 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()) { if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true; $linked = true;
@@ -107,8 +112,8 @@ class InvestmentDiscoveryController extends Controller
$returnFilters = $request->query(); $returnFilters = $request->query();
unset($returnFilters['zone_id'], $returnFilters['from_dossier']); 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, 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(\App\Services\SectorInvestment::class)->guides($record->sector), 'guides' => app(SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null, 'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]); 'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
} }
@@ -6,6 +6,9 @@ use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission; use App\Models\InvestmentSubmission;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Project; use App\Models\Project;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations; use App\Services\InvestmentRelations;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
@@ -112,7 +115,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) { $opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false'); $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) { foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) { if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id')); $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']) ->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect(); : 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, '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), 'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]); '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); self::visible()->findOrFail($id);
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]); $data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1'])); return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
} }
@@ -8,6 +8,7 @@ use App\Models\EconomicCluster;
use App\Models\InvestmentGap; use App\Models\InvestmentGap;
use App\Models\Project; use App\Models\Project;
use App\Models\ValueChain; use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse; 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') ->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
->orderBy('origins.code')->orderBy('destinations.code') ->orderBy('origins.code')->orderBy('destinations.code')
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [], ->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, 'viewerId' => $request->user()->id,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects') 'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id') ->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
@@ -10,6 +10,7 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\InterestSubmission; use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentWorkspace; use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers; use App\Services\InvestorContextConsumers;
@@ -151,7 +152,7 @@ class InvestmentSubmissionController extends Controller
public function bindContext(Request $request, string $id): JsonResponse 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_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']); $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) { $result = DB::transaction(function () use ($request, $id, $data) {
// Serialize all links for this submission before locking the selected case. // 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(); $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); if ($bound) {
else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']); $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; return $data;
} }
@@ -271,7 +276,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) { $version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true); $row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.'); 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(); InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) { foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); 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.'); abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$scopeKey = $data['payload']['context_opportunity'] ?? null; $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) { if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($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, 'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []), ...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]])); ...($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(); InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) { foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -5,14 +5,17 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier; use App\Models\InvestmentDossier;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\EconomicReferenceCatalog;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentTerritorialTraceability; use App\Services\InvestmentTerritorialTraceability;
use App\Services\InvestmentZoneAccess; use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones; use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalDetailsInstallation;
use App\Services\InvestmentZoneOperationalProfile; use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext; use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation; use App\Services\InvestmentZoneProfileInstallation;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; 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('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district); $zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
$zone->setAttribute('territory_path', $territoryPath); $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']; $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') $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')) ->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, $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(), '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'), 'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
'related' => $related->unique('id')->values(), 'related' => $related->unique('id')->values(),
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [], '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, 'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null,
'opportunities' => $opportunities, 'opportunities' => $opportunities,
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [], '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']) : [], '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() : [], 'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
'locations' => $locations, '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.'); '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); $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']; $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))); $publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id') $rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString(); ->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')) ->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'])); ->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')]; 'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props); 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'], 'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'], 'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'], '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' => ['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.*.id' => ['nullable', 'uuid'],
'activities.*.source_date' => ['nullable','date_format:Y-m-d'], 'activities.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'], 'activities.*.kind' => ['nullable', 'in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable','in:inside,regional,unknown'], 'activities.*.scope' => ['nullable', 'in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable','string','max:120'], 'activities.*.capacity_type' => ['nullable', 'string', 'max:120'],
'activities.*.capacity_value' => ['nullable','numeric','min:0'], 'activities.*.capacity_value' => ['nullable', 'numeric', 'min:0'],
'activities.*.capacity_unit' => ['nullable','string','max:40'], 'activities.*.capacity_unit' => ['nullable', 'string', 'max:40'],
'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'], 'activities.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'], 'activities.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes','array','max:10'], 'activities.*.support_document_ids' => ['sometimes', 'array', 'max:10'],
'activities.*.support_document_ids.*' => ['uuid'], '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.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'], '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' => ['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.*.id' => ['nullable', 'uuid'],
'resources.*.source_date' => ['nullable','date_format:Y-m-d'], 'resources.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'], 'resources.*.kind' => ['nullable', 'in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable','in:inside,regional,unknown'], 'resources.*.scope' => ['nullable', 'in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable','string','max:120'], 'resources.*.capacity_type' => ['nullable', 'string', 'max:120'],
'resources.*.capacity_value' => ['nullable','numeric','min:0'], 'resources.*.capacity_value' => ['nullable', 'numeric', 'min:0'],
'resources.*.capacity_unit' => ['nullable','string','max:40'], 'resources.*.capacity_unit' => ['nullable', 'string', 'max:40'],
'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'], 'resources.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'], 'resources.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes','array','max:10'], 'resources.*.support_document_ids' => ['sometimes', 'array', 'max:10'],
'resources.*.support_document_ids.*' => ['uuid'], '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.*.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'], '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'], '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; $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); return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201);
} }
public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse 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']]); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version' => ['required_if:action,reconcile', 'nullable', 'integer', 'min:1']]);
if($action==='reconcile'){ if ($action === 'reconcile') {
$request->validate(['base_public_version'=>'required|integer|min:1']); $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);}); $saved = DB::transaction(function () use ($id, $data, $request) {
}else{$saved=$action === 'publish' ? $zones->publish($id,$data['version_no'],$request->user()->id) : $zones->suspend($id,$data['version_no'],$request->user()->id);} $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)); return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
} }
@@ -2,82 +2,106 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Services\InvestmentComplements;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityChoices;
use App\Services\InvestorContext; use App\Services\InvestorContext;
use App\Services\InvestorContextInstallation; use App\Services\InvestorContextInstallation;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
final class InvestorContextController extends Controller final class InvestorContextController extends Controller
{ {
/**
* @return array<array-key, mixed>
*/
private function entryComplements(Request $request): 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', $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']); 'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']);
if (empty($data['complement_origin'])) return []; if (empty($data['complement_origin'])) {
return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'], 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]]); 'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
} }
/**
* @return array<array-key, mixed>
*/
private function entryChoices(Request $r, InvestorContext $context): 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', $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']); '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']] : []); $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); $path = app(InvestmentGeography::class)->selection($data);
$input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids); $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']]; if (! empty($data['from_dossier'])) {
$input[0]['portfolio_origins'] = [$data['from_dossier']];
}
foreach ($this->entryComplements($r) as $complement) { 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) { 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']] : []), $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)]] : [])]; ...(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())); 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())); 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']); $data = $r->validate(['opportunity' => 'nullable|string|max:100']);
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null)); 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']); $d = $r->validate(['q' => 'nullable|string|max:120']);
return response()->json($context->options($d['q'] ?? '')); 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']); $data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(\App\Services\InvestmentCorridorContext::class); $service = app(InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(),
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)]); '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())); 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); $data = $context->view($r->user(), $id);
abort_unless($data['can_write'], 403); abort_unless($data['can_write'], 403);
@@ -85,72 +109,84 @@ final class InvestorContextController extends Controller
$entries = $this->entryChoices($r, $context); $entries = $this->entryChoices($r, $context);
$choices = $entries ?: null; $choices = $entries ?: null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : 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']]]); if ($choices === null && ($source['kind'] ?? '') === 'opportunity') {
$explicitChoices=$choices; $choices = app(InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
$selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null; }
$entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:''); $explicitChoices = $choices;
$entrySources=array_map($entryKey,$explicitChoices??[]); $selectionKey = $r->validate(['selection_key' => 'nullable|uuid'])['selection_key'] ?? null;
if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices); $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); $draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) { if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true); $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)); $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)); $choices = app(InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
} }
$differences = []; $differences = [];
foreach ($explicitChoices ?? [] as $entry) { foreach ($explicitChoices ?? [] as $entry) {
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']); $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; $entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') { 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'])); $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) { foreach ($this->entryComplements($r) as $entry) {
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? '')); $tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
$entrySources[] = $tag; $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())); 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())); 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])$/']]); $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; $ref = isset($d['source']) ? $context->reference($d['source']) : null;
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null; $submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
$entrySubmission = null; $entrySubmission = null;
if ($submissionId) { 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.'); 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]; $entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
} }
$choices = $this->entryChoices($r, $context); $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]); 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()); $plan = $install->preview($r->user());
return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]); 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()));
} }
} }
+21 -12
View File
@@ -3,8 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\FintechInvestment; use App\Services\FintechInvestment;
use App\Services\InvestorPath;
use App\Services\InvestmentZonePathContext; use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\SectorInvestment; use App\Services\SectorInvestment;
use Illuminate\Http\JsonResponse; 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.'); 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); abort_unless($path->available(), 404);
$data=$request->validate(['consulted_public_version'=>'required|integer|min:1']); $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'])]; $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);
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()); 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.');
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 public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{ {
$zone=$request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null; $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']); } 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; $sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null;
if ($sector !== null) { if ($sector !== null) {
app(SectorInvestment::class)->context($sector); 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]); 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; return $id;
}); });
@@ -377,6 +377,9 @@ class LandAssistanceController extends Controller
'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)]; 'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)];
} }
/**
* @return array<array-key, mixed>|null
*/
private function dossierContext(LandAssistanceCase $case): ?array private function dossierContext(LandAssistanceCase $case): ?array
{ {
$id = $case->payload['investor_path_case_id'] ?? null; $id = $case->payload['investor_path_case_id'] ?? null;
@@ -12,9 +12,12 @@ use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation; use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity; use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow; use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityPromotion;
use App\Services\OpportunityResources; use App\Services\OpportunityResources;
use App\Services\OpportunityWorkflowTasks; use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\ProactiveInvestment;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -76,8 +79,8 @@ class OpportunityManagementController extends Controller
$query->where('opportunity_category', $filters['category']); $query->where('opportunity_category', $filters['category']);
} }
if ($filters['sector_id'] ?? null) { if ($filters['sector_id'] ?? null) {
$sector = Sector::find($filters['sector_id']); $sector = Sector::whereKey($filters['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
if ($filters['geography_scope'] ?? null) { if ($filters['geography_scope'] ?? null) {
$query->where('geography_scope', $filters['geography_scope']); $query->where('geography_scope', $filters['geography_scope']);
@@ -103,7 +106,7 @@ class OpportunityManagementController extends Controller
$records = $query->orderByDesc('id')->paginate(20)->withQueryString(); $records = $query->orderByDesc('id')->paginate(20)->withQueryString();
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) { $records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r)); $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('working_status', $access->state($r));
$r->setAttribute('publication', $r->publicationPresentation()); $r->setAttribute('publication', $r->publicationPresentation());
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title); $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 'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false, ? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
'publicationState' => $record?->publicationPresentation(), '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, 'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false, 'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []], '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'] ?? [])); 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; $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], '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), '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']) : []]); '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']) : []]);
@@ -4,22 +4,24 @@ namespace App\Http\Controllers;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\OpportunityPromotion; use App\Services\OpportunityPromotion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
final class OpportunityPromotionController extends Controller 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))); 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())); 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())); return response()->json($service->confirm($request->user(), $id, $request->all()));
} }
@@ -6,9 +6,11 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\OpportunityImageProcessor; use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityLibrarySupport;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation; use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources; use App\Services\OpportunityResources;
use App\Services\OpportunityTerritorialPresentation;
use App\Services\OrganizationCountryInstallation; use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\VerifiedDocumentBinary; 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 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.']); 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); abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id); $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); 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.*']); $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 !== 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.'); 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(); $support = DB::table($service->table($kind))->where('id', $resourceId)->first();
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record); 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.'); 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); return app(VerifiedDocumentBinary::class)->respond($record);
} }
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Models\User; use App\Models\User;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
@@ -11,6 +12,7 @@ use App\Services\InvestmentRelations;
use App\Services\PartnershipExchange; use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching; use App\Services\PartnershipMatching;
use App\Services\PartnershipSources; use App\Services\PartnershipSources;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -45,9 +47,11 @@ class PartnershipExchangeController extends Controller
foreach (['sector_id', 'partnership_type', 'kind'] as $field) { foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) { if (! empty($filters[$field])) {
if ($field === 'sector_id') { if ($field === 'sector_id') {
$sector = \App\Models\Sector::find($filters[$field]); $sector = Sector::whereKey($filters[$field])->first();
$query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('p.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else { $query->where('p.'.$field, $filters[$field]); } } else {
$query->where('p.'.$field, $filters[$field]);
}
} }
} }
if (! empty($filters['q'])) { 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']), '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) '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']) : []]; ->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []];
@@ -15,6 +15,8 @@ use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -55,32 +57,38 @@ class PortalManagementController extends Controller
private function interestAssignmentEtag(InterestRequest $interest): string 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 public function interestAssignments(Request $request): Response|JsonResponse
{ {
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); 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']) $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]); ->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'])]; $data = ['kind' => 'interest', 'requests' => $rows, 'analysts' => $this->interestAssignees()->get(['id', 'name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
} }
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{ {
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403); 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.']); $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){ DB::transaction(function () use ($request, $interest, $data) {
$row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); $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 (! hash_equals($this->interestAssignmentEtag($row), $data['etag'])) {
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.']); throw ValidationException::withMessages(['etag' => 'A atribuição mudou. Recupere a lista actual e tente novamente.']);
$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.'); if (in_array($row->status, ['cancelled', 'closed', 'refused', 'converted'], true)) {
PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id); throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]); }
$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 public function interests(Request $request): Response
@@ -95,7 +103,7 @@ class PortalManagementController extends Controller
$query = InterestRequest::query()->with('assignee:id,name')->select([ $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', '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) { if ($country = $filters['country'] ?? null) {
$values = app(CountryCatalogue::class)->filterValues($country); $values = app(CountryCatalogue::class)->filterValues($country);
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values); $query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
@@ -142,7 +150,7 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response 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) $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']) ->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) { ->map(function ($row) {
@@ -170,7 +178,7 @@ class PortalManagementController extends Controller
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse 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([ $validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)], 'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'], 'note' => ['nullable', 'string', 'min:10', 'max:2000'],
@@ -198,7 +206,7 @@ class PortalManagementController extends Controller
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse 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']]); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent'); $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 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']]); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest'); $this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
@@ -301,8 +309,8 @@ class PortalManagementController extends Controller
} }
} }
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) { if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$sector = Sector::find($filters['sector_id']); $sector = Sector::whereKey($filters['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
return Inertia::render('management/catalog', [ return Inertia::render('management/catalog', [
@@ -319,7 +327,7 @@ class PortalManagementController extends Controller
public function edit(string $resource, ?int $id = null): Response public function edit(string $resource, ?int $id = null): Response
{ {
$model = $resource === 'sectors' ? Sector::class : Opportunity::class; $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', [ return Inertia::render('management/editor', [
'resource' => $resource, '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.']); 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; $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' ? [ $rules = $resource === 'sectors' ? [
'name' => ['required', 'string', 'max:150'], 'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)], 'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityCanonicalContract; use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts; use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement; 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(), 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(), 'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(), '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, 'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES, 'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']), 'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
@@ -30,6 +30,7 @@ class PortalProfileInstallationController extends Controller
public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response
{ {
$plan = $installation->bootstrapPreview($request->user(), $id); $plan = $installation->bootstrapPreview($request->user(), $id);
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan); return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
} }
@@ -4,13 +4,20 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest; use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\DocumentManagementAccess;
use App\Services\EconomicReferenceCatalog;
use App\Services\EditorialReading;
use App\Services\IdempotencyConflict; use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission; use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner; use App\Services\Malware\MalwareScanner;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk; use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
@@ -18,6 +25,7 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -26,6 +34,7 @@ use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PortalServicesController extends Controller class PortalServicesController extends Controller
{ {
@@ -70,7 +79,7 @@ class PortalServicesController extends Controller
]); ]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()]; $props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') { if ($page === 'sectors') {
$props['sectors'] = \App\Services\SectorOrganization::catalog(); $props['sectors'] = SectorOrganization::catalog();
} }
if (in_array($page, ['documents', 'legislation'], true)) { if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources(); $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', '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', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->paginate(20)->withQueryString(); ])->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['resources'] = $pageResult;
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type'); $props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language'); $props['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) $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']); ->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]); return response()->json(['data' => $resources]);
} }
@@ -266,7 +275,7 @@ class PortalServicesController extends Controller
abort_unless($resource !== null, 404); abort_unless($resource !== null, 404);
$resource->territory_name = $resource->territory_id $resource->territory_name = $resource->territory_id
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null; ? 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'); $back = $request->query('back');
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//') $backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
? $back : '/documentos'; ? $back : '/documentos';
@@ -289,7 +298,7 @@ class PortalServicesController extends Controller
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response 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' abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404); && 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 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']); $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())) { if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted'); $documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health(); $health = $scanner->health();
@@ -314,6 +323,7 @@ class PortalServicesController extends Controller
$resource->links = $links->get($resource->id, collect())->values(); $resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values(); $resource->revisions = $revisions->get($resource->id, collect())->values();
}); });
return Inertia::render('portal/manage', [ return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [], '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()]); 'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
} }
/**
* @param array<array-key, mixed> $filters
*/
private function filterManagedResources(Builder $query, array $filters): Builder private function filterManagedResources(Builder $query, array $filters): Builder
{ {
if (! empty($filters['q'])) { 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'])); $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 $query;
} }
/**
* @return array<array-key, mixed>
*/
private function contentAssociations(Request $request): 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') $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); : ($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(); $documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
return [ return [
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']), 'sector' => 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']), 'territory' => app(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']), 'opportunity' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents, 'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(), 'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(), 'stage' => collect(Config::array('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(), '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 { $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(); $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($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); $identity = $this->identityKey($data);
if ($identity) { if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$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 { DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); $resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404); 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.'); 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(); $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($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); $identity = $this->identityKey($data);
if ($identity) { if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$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 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())) { if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403); abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id); $access->assertEditableResource($request->user(), $id);
@@ -519,7 +537,9 @@ class PortalServicesController extends Controller
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships'; $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) { DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404); 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']) { if ($type === 'resources' && $data['published']) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') $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', ->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(); && DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
} }
/** @return list<string> */
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<string> */ /** @return list<string> */
private function territoryScopeIds(string $id): array private function territoryScopeIds(string $id): array
{ {
@@ -866,17 +875,23 @@ SQL, [$id]);
return $ids ?: [$id]; return $ids ?: [$id];
} }
private function articleUrl(int $id): ?string private function articleUrl(int $id): ?string
{ {
if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null; if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
$resource = DB::table('portal_resources')->find($id); return null;
if (!$resource) return null; }
if ($resource->editorial_content !== null) return '/artigos/'.$id; $resource = DB::table('portal_resources')->where('id', $id)->first();
if (! $resource) {
return null;
}
if ($resource->editorial_content !== null) {
return '/artigos/'.$id;
}
try { try {
return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null; return app(EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException) { } catch (HttpException) {
return null; return null;
} }
} }
} }
@@ -1,17 +1,19 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\ProactiveSupply; use App\Services\ProactiveSupply;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ProactiveSupplyController extends Controller final class ProactiveSupplyController extends Controller
{ {
public function index(Request $request,ProactiveSupply $service): JsonResponse public function index(Request $request, ProactiveSupply $service): JsonResponse
{ {
$v=$request->validate(['record_id'=>'nullable|integer|min:1']); $v = $request->validate(['record_id' => 'nullable|integer|min:1']);
$record=isset($v['record_id'])?Opportunity::findOrFail($v['record_id']):null; $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');
return response()->json($service->search($request->user(), $request->all(), $record))->header('Cache-Control', 'private, no-store');
} }
} }
+27 -20
View File
@@ -3,22 +3,26 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Procurement; use App\Services\Procurement;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementController extends Controller 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 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); 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'); 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 = $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 !== ''); $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(); $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<array-key, mixed>
*/
private function summary(Procurement $service, string $vid): array private function summary(Procurement $service, string $vid): array
{ {
$v = $service->publishedVersion($vid); $v = $service->publishedVersion($vid);
@@ -58,62 +65,62 @@ class ProcurementController extends Controller
return ['publication' => $v, 'state' => $service->state($v)]; 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); 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); 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']); $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']); $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)), '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()); $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()); $service->guard($request->user());
$r = $id ? $service->record($id) : null; $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, 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)->header('Cache-Control', 'private, no-store'); '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()); $id = $service->save($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.'); 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'); 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()); $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.'); 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']); $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()]); 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.'); 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); abort_unless($service->available(), 404);
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete(); 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(); 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)]); $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'));
} }
} }
@@ -4,11 +4,13 @@ namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource; use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessClassification; use App\Services\ExistingBusinessClassification;
use App\Services\OpportunityClassification; use App\Services\ExistingBusinessDrafts;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\OpportunityClassification;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -43,8 +45,8 @@ class PublicBusinessOpportunityController extends Controller
->orWhereLike('existing_businesses.objective', '%'.$term.'%')); ->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
} }
if (! empty($filters['sector'])) { if (! empty($filters['sector'])) {
$sector = \App\Models\Sector::find($filters['sector']); $sector = Sector::whereKey($filters['sector'])->first();
$query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('existing_businesses.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
if (! empty($filters['modality'])) { if (! empty($filters['modality'])) {
$query->where('existing_businesses.modality', $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'])]); 'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
} }
/**
* @return array<array-key, mixed>
*/
private function filters(Request $request): array private function filters(Request $request): array
{ {
return $request->validate([ return $request->validate([
@@ -2,21 +2,26 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PublicGeographyController extends Controller class PublicGeographyController extends Controller
{ {
public function options(Request $request, InvestmentGeography $geography): JsonResponse 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']); $query = $geography->publicReferences()->where('location_type', $data['type']);
if (!empty($data['corridor'])) { if (! empty($data['corridor'])) {
$corridors = app(\App\Services\InvestmentCorridorContext::class); $corridors = app(InvestmentCorridorContext::class);
if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id')); if ($data['type'] === 'province') {
elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.'); $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'])) { if (! empty($data['id'])) {
$query->where('id', $data['id']); $query->where('id', $data['id']);
@@ -6,6 +6,8 @@ use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\SectorInvestment; use App\Services\SectorInvestment;
use App\Services\SectorOrganization;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -19,10 +21,14 @@ class PublicPortalController extends Controller
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']); 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 (in_array($slug, ['energia', 'fintech'], true)) {
if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech'; 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(); $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)]); return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]);
@@ -1,20 +1,24 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\SectorReconciliationExecution; use App\Services\SectorReconciliationExecution;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class SectorReconciliationController extends Controller final class SectorReconciliationController extends Controller
{ {
public function preview(Request $request, SectorReconciliationExecution $execution) public function preview(Request $request, SectorReconciliationExecution $execution): JsonResponse|Response
{ {
$plan = $execution->preview($request); $plan = $execution->preview($request);
return $request->expectsJson() ? response()->json($plan)->header('Cache-Control', 'private, no-store') 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'); return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store');
} }
+20 -13
View File
@@ -10,6 +10,7 @@ use App\Services\CountryCatalogue;
use App\Services\InstitutionalLinkMonitor; use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers; use App\Services\InvestorContextConsumers;
use App\Services\ScopedPortalAdministration;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk; use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -21,6 +22,7 @@ use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse; 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)], $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, '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), '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, []), '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(), '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), '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 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. // 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']), $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'])]; 'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
} }
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{ {
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403); 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.']); $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) { DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first(); $row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first();
abort_unless($row,404); abort_unless($row !== null, 404);
$case = DB::table('desk_cases')->where('id',$row->case_id)->first(); $case = DB::table('desk_cases')->where('id', $row->case_id)->first();
abort_unless($case,404); abort_unless($case !== null, 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 ((int) $row->version !== (int) $data['version']) {
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.']); 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(); $analyst = User::where('id', $data['analyst_id'])->firstOrFail();
abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.'); 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.'); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
@@ -12,7 +12,7 @@ class EnsureDocumentManagement
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$actor = $request->user(); $actor = $request->user();
abort_unless($actor, 403); abort_unless($actor !== null, 403);
$access = app(DocumentManagementAccess::class); $access = app(DocumentManagementAccess::class);
if ($access->full($actor)) { if ($access->full($actor)) {
return $next($request); // Preserve existing management controls. return $next($request); // Preserve existing management controls.
@@ -1,37 +1,50 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** Environment reservation is not a portal identity or a business permission. */ /** Environment reservation is not a portal identity or a business permission. */
class EnsureHomologationAccess class EnsureHomologationAccess
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (!config('portal.homologation_only', false)) return $next($request); if (! config('portal.homologation_only', false)) {
$mode=config('portal.homologation_access_mode', 'reviewers'); return $next($request);
if ($mode==='perimeter') { }
// Set only by the trusted server after its environment-access check. $mode = config('portal.homologation_access_mode', 'reviewers');
// A client HTTP_INVESTMOZ_* header is deliberately not accepted. if ($mode === 'perimeter') {
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403, // Set only by the trusted server after its environment-access check.
'Este ambiente de homologação tem acesso reservado à Tempo.'); // A client HTTP_INVESTMOZ_* header is deliberately not accepted.
$response=$next($request); abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS') === 'granted', 403,
$response->headers->set('Cache-Control','private, no-store'); 'Este ambiente de homologação tem acesso reservado à Tempo.');
$response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive'); $response = $next($request);
return $response; $response->headers->set('Cache-Control', 'private, no-store');
} $response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive');
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured. return $response;
if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*', }
'email/verify','email/verify/*','email/verification-notification','two-factor-challenge', abort_unless($mode === 'reviewers', 503, 'A reserva da homologação não está configurada.');
'user/confirm-password','user/confirmed-password-status','user/two-factor-*', // Compatibility until an independent perimeter is explicitly configured.
'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request); if ($request->is('login', 'logout', 'register', 'forgot-password', 'reset-password', 'reset-password/*',
$actor=$request->user(); 'email/verify', 'email/verify/*', 'email/verification-notification', 'two-factor-challenge',
if (!$actor) return $request->expectsJson() 'user/confirm-password', 'user/confirmed-password-status', 'user/two-factor-*',
? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401) 'settings/security', 'settings/password', 'user/password', 'passkeys/*', 'user/passkeys/*', 'up')) {
: redirect()->guest(route('login')); return $next($request);
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.'); $actor = $request->user();
$response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response; 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;
}
} }
@@ -1,15 +1,19 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class EnsureScopedEditorial class EnsureScopedEditorial
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$actor = $request->user(); $actor = $request->user();
abort_unless($actor, 403); abort_unless($actor !== null, 403);
$access = app(\App\Services\DocumentManagementAccess::class); $access = app(DocumentManagementAccess::class);
if ($access->full($actor)) { if ($access->full($actor)) {
return $next($request); return $next($request);
} }
@@ -20,6 +24,7 @@ class EnsureScopedEditorial
if ($request->route('id') !== null) { if ($request->route('id') !== null) {
$access->assertEditableResource($actor, (int) $request->route('id')); $access->assertEditableResource($actor, (int) $request->route('id'));
} }
return $next($request); return $next($request);
} }
} }
@@ -64,6 +64,7 @@ final class EnsureSimulationRecoveryCookie
false, false,
'lax', 'lax',
)); ));
return $response; return $response;
} }
+15 -11
View File
@@ -3,10 +3,13 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Services\ContentManagement;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\Procurement;
use App\Services\ScopedPortalAdministration;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -48,15 +51,16 @@ class HandleInertiaRequests extends Middleware
*/ */
public function share(Request $request): array public function share(Request $request): array
{ {
$contentManagementAccess=app(\App\Services\ContentManagement::class)->access($request->user()); $contentManagementAccess = app(ContentManagement::class)->access($request->user());
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'procurementEnabled' => app(\App\Services\Procurement::class)->available(), 'procurementEnabled' => app(Procurement::class)->available(),
'homologationReserved' => (bool) config('portal.homologation_only', false), 'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()), 'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null, 'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
'contentManagementAccess'=>$contentManagementAccess, 'contentManagementAccess' => $contentManagementAccess,
'portalOperator' => config('portal.operator_name'), 'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null, 'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(), 'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -73,14 +77,14 @@ class HandleInertiaRequests extends Middleware
'installationHref' => '/management/opportunity-management-install', 'installationHref' => '/management/opportunity-management-install',
] : null, ] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')], '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()) ? [ 'scopedAdministration' => $request->user() && app(ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'], ['label' => 'Zonas e parques', 'href' => '/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'], ['label' => 'Referências económicas', 'href' => '/management/economic-references/lot-01'],
['label'=>'Referências e formulários','href'=>'/management/investment/references'], ['label' => 'Referências e formulários', 'href' => '/management/investment/references'],
['label'=>'Educação e competências','href'=>'/management/education'], ['label' => 'Educação e competências', 'href' => '/management/education'],
['label'=>'Interesses atribuídos','href'=>'/management/interests'], ['label' => 'Interesses atribuídos', 'href' => '/management/interests'],
['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'], ['label' => 'Atribuições de interesses', 'href' => '/management/interests/atribuicoes'],
['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'], ['label' => 'Atribuições do Balcão', 'href' => '/management/balcao/atribuicoes'],
] : [], ] : [],
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user(),
+6 -2
View File
@@ -13,15 +13,19 @@ class PortalProfileSession
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (! $request->user()) {
return $next($request);
}
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
$available = $access->governanceAvailable(); $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') $recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap')
|| ($request->is('logout') && $request->isMethod('POST')); || ($request->is('logout') && $request->isMethod('POST'));
abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503, 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.'); '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'); $revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
$bound = $request->session()->get('portal.access_revision'); $bound = $request->session()->get('portal.access_revision');
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) { if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
@@ -13,15 +13,22 @@ class VerifiedOrSimpleParticipation
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if ($request->user()->hasVerifiedEmail()) return $next($request); if ($request->user()->hasVerifiedEmail()) {
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request); return $next($request);
}
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) {
return $next($request);
}
$id = $request->route('id'); $id = $request->route('id');
$show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$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'); $message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages');
if ($id && ($show || $message)) { if ($id && ($show || $message)) {
$row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail(); $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); return app(EnsureEmailIsVerified::class)->handle($request, $next);
} }
} }
@@ -6,8 +6,10 @@ namespace App\Http\Requests;
use App\Models\LandAssistanceCase; use App\Models\LandAssistanceCase;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Validator; use Illuminate\Validation\Validator;
@@ -19,9 +21,14 @@ class StoreLandAssistanceCaseRequest extends FormRequest
{ {
$payload = $this->validated('payload'); $payload = $this->validated('payload');
if (!is_array($payload)) return []; if (! is_array($payload)) {
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 [];
return \Illuminate\Support\Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']); }
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 public function authorize(): bool
@@ -2,6 +2,7 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Services\ExistingBusinessClassification;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -27,7 +28,7 @@ class PublicBusinessOpportunityResource extends JsonResource
'province' => $this->resource->getAttribute('province'), 'province' => $this->resource->getAttribute('province'),
'district' => $this->resource->getAttribute('district'), 'district' => $this->resource->getAttribute('district'),
'locality' => $this->resource->getAttribute('locality'), 'locality' => $this->resource->getAttribute('locality'),
'multidimensional' => app(\App\Services\ExistingBusinessClassification::class)->publicRows($this->resource), 'multidimensional' => app(ExistingBusinessClassification::class)->publicRows($this->resource),
]; ];
} }
} }
+6
View File
@@ -2,4 +2,10 @@
namespace App\Models; 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 {} class Contact extends CrmRecord {}
+1
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model;
* @property string $validation_status * @property string $validation_status
* @property string $valid_from * @property string $valid_from
* @property string|null $valid_until * @property string|null $valid_until
* @property array<string, mixed>|null $fiscal_acceptance
*/ */
class InvestmentLegalSource extends Model class InvestmentLegalSource extends Model
{ {
+1 -1
View File
@@ -24,7 +24,7 @@ class InvestmentZone extends DomainRecord
{ {
protected $hidden = ['editorial_draft', 'operational_details']; 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<GeoLocation, $this> */ /** @return BelongsTo<GeoLocation, $this> */
public function primaryTerritory(): BelongsTo public function primaryTerritory(): BelongsTo
+11 -3
View File
@@ -2,6 +2,9 @@
namespace App\Models; namespace App\Models;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityPublicText;
use App\Services\ProactiveInvestment;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -34,8 +37,13 @@ class Opportunity extends Model
public function toArray(): array public function toArray(): array
{ {
$data = parent::toArray(); $data = parent::toArray();
if (isset($data['description'])) $data['description'] = \App\Services\OpportunityPublicText::description($this, $data['description']); if (isset($data['description'])) {
if ($this->exists && ($proactive = app(\App\Services\ProactiveInvestment::class)->publicData($this))) $data['proactive'] = $proactive; $data['description'] = OpportunityPublicText::description($this, $data['description']);
}
if ($this->exists && ($proactive = app(ProactiveInvestment::class)->publicData($this))) {
$data['proactive'] = $proactive;
}
return $data; return $data;
} }
@@ -44,7 +52,7 @@ class Opportunity extends Model
// Only published metadata is public; never expose the current private draft. // Only published metadata is public; never expose the current private draft.
$version = data_get($this->workflow_context, 'publication.maturity.catalogue_version'); $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; ? $version : null;
} }
+15 -4
View File
@@ -2,10 +2,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property list<array<string, mixed>> $specializations
* @property string $url
*/
class Sector extends Model class Sector extends Model
{ {
use SoftDeletes; use SoftDeletes;
@@ -20,18 +25,24 @@ class Sector extends Model
'published_at' => 'datetime', 'published_at' => 'datetime',
]; ];
/** Presentation only: historical masters and foreign keys remain untouched. */ /** Presentation only: historical masters and foreign keys remain untouched.
public function scopeChoices($query) * @param Builder<Sector> $query
* @return Builder<Sector>
*/
public function scopeChoices(Builder $query): Builder
{ {
return $query->where('is_active', true)->where('slug', '!=', 'energia'); return $query->where('is_active', true)->where('slug', '!=', 'energia');
} }
public function scopeCatalog($query) /** @param Builder<Sector> $query
* @return Builder<Sector>
*/
public function scopeCatalog(Builder $query): Builder
{ {
return $query->choices()->whereNotNull('published_at')->where('slug', '!=', 'fintech'); 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; return ($this->attributes['slug'] ?? null) === 'fintech' ? 'Mercado Financeiro — Fintech' : $value;
} }
+3
View File
@@ -11,6 +11,9 @@ use Illuminate\Support\Carbon;
* @property string $component * @property string $component
* @property string $scope_key * @property string $scope_key
* @property string $status * @property string $status
* @property array<string, mixed> $rule_definition
* @property array<string, mixed> $parameters
* @property array<string, mixed> $rounding_policy
* @property int $author_id * @property int $author_id
* @property int|null $reviewer_id * @property int|null $reviewer_id
* @property int|null $approver_id * @property int|null $approver_id
+16 -7
View File
@@ -4,6 +4,7 @@ namespace App\Policies;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\User; use App\Models\User;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -87,11 +88,13 @@ class ExistingBusinessPolicy
return $this->designatedAny($user); 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<ExistingBusiness> $query
*/
public function scopeReviewQueue(Builder $query, User $user): void public function scopeReviewQueue(Builder $query, User $user): void
{ {
if (app(PortalProfileAccess::class)->available()) { 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(); $ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys();
$query->whereKey($ids); $query->whereKey($ids);
} }
@@ -119,13 +122,18 @@ class ExistingBusinessPolicy
return false; return false;
} }
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
if ($access->has($user, 'auditor')) { return false; } if ($access->has($user, 'auditor')) {
return false;
}
if (! $access->available()) { if (! $access->available()) {
return $access->legacyWriteAllowed($user) && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret 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); && 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'; $responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
return collect($access->grants($user, 'validator'))->contains(fn ($g) => $g->organization_id === null 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('business_sale', json_decode($g->opportunity_types, true), true)
&& in_array($responsibility, json_decode($g->responsibilities, 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)) { || app(PortalProfileAccess::class)->has($user, 'auditor') || ! $this->designated($user, 'publishers', $business)) {
return false; return false;
} }
return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id) return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists(); ->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists();
} }
@@ -156,8 +165,8 @@ class ExistingBusinessPolicy
{ {
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
if ($access->available()) { if ($access->available()) {
$category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null; $category = $business ? ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) { if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user, 'auditor')) {
return false; return false;
} }
$role = $designation === 'publishers' ? 'publisher' : 'validator'; $role = $designation === 'publishers' ? 'publisher' : 'validator';
@@ -168,7 +177,7 @@ class ExistingBusinessPolicy
$organizationMatches = $grant->organization_id === null $organizationMatches = $grant->organization_id === null
|| ($business !== null && $grant->organization_id === $business->organization_id); || ($business !== null && $grant->organization_id === $business->organization_id);
if ($organizationMatches 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; return true;
} }
} }
+6 -2
View File
@@ -22,6 +22,8 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LogoutResponse;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided; use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -45,12 +47,12 @@ class AppServiceProvider extends ServiceProvider
// Fortify calls this response AFTER invalidating/regenerating the server session. // Fortify calls this response AFTER invalidating/regenerating the server session.
// Clear the client's encryption key too, so Back must reauthorize private pages. // 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 return new class extends \Laravel\Fortify\Http\Responses\LogoutResponse
{ {
public function toResponse($request) public function toResponse($request)
{ {
\Inertia\Inertia::clearHistory(); Inertia::clearHistory();
return parent::toResponse($request); return parent::toResponse($request);
} }
@@ -77,6 +79,8 @@ class AppServiceProvider extends ServiceProvider
Cache::forget('portal_home_en'); Cache::forget('portal_home_en');
Cache::forget('portal_home_v2_pt'); Cache::forget('portal_home_v2_pt');
Cache::forget('portal_home_v2_en'); 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::saved($invalidate);
$model::deleted($invalidate); $model::deleted($invalidate);
+2 -2
View File
@@ -24,8 +24,8 @@ class InvestorGuidance
return Schema::hasColumn('tasks', 'desk_case_id'); 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. */ /** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator.
/** @return array<string,mixed> */ * @return array<string,mixed> */
public function context(User $actor, \stdClass $case): array public function context(User $actor, \stdClass $case): array
{ {
$base = $this->desk->decode($case->payload); $base = $this->desk->decode($case->payload);
+3 -1
View File
@@ -103,7 +103,9 @@ final class BusinessFormSchema
// The field limit is a text length, not an 8% ceiling. // The field limit is a text length, not an 8% ceiling.
$rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100', $rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100',
function ($attribute, $value, $fail) { 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) { foreach (['valid_until', 'mandate_valid_until'] as $date) {
$rules['payload.'.$date][] = 'date_format:Y-m-d'; $rules['payload.'.$date][] = 'date_format:Y-m-d';
+25 -3
View File
@@ -23,17 +23,26 @@ final class ClusterCompetitiveness
->where('entity_type', 'economic_clusters')->where('entity_id', $cluster); ->where('entity_type', 'economic_clusters')->where('entity_id', $cluster);
} }
private function decode(?object $event): array /**
* @return array<array-key, mixed>
*/
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) 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]; : ['version' => 0, 'draft' => null, 'approved' => null];
} }
/**
* @return array<array-key, mixed>
*/
public function current(string $cluster): array public function current(string $cluster): array
{ {
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first()); return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
} }
/**
* @return array<array-key, mixed>
*/
public function management(string $cluster): array public function management(string $cluster): array
{ {
$state = $this->current($cluster); $state = $this->current($cluster);
@@ -44,6 +53,10 @@ final class ClusterCompetitiveness
return $state; return $state;
} }
/**
* @param array<array-key, mixed> $draft
* @return array<array-key, mixed>
*/
public function validateDraft(array $draft, string $cluster): array public function validateDraft(array $draft, string $cluster): array
{ {
$rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster, $rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster,
@@ -69,6 +82,9 @@ final class ClusterCompetitiveness
return $clean; return $clean;
} }
/**
* @param array<array-key, mixed>|null $draft
*/
private function assertComplete(?array $draft): void private function assertComplete(?array $draft): void
{ {
$missing = ! $draft || ! $draft['sector'] || ! $draft['region']; $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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): 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) { 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); $state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') { } elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); $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']; $state['draft'] = $this->decode($event)['draft'];
} elseif ($action === 'approved') { } elseif ($action === 'approved') {
$this->assertComplete($state['draft']); $this->assertComplete($state['draft']);
@@ -120,6 +139,9 @@ final class ClusterCompetitiveness
}); });
} }
/**
* @return array<array-key, mixed>|null
*/
public function publicData(string $cluster): ?array public function publicData(string $cluster): ?array
{ {
$approved = $this->current($cluster)['approved']; $approved = $this->current($cluster)['approved'];
+17 -3
View File
@@ -9,9 +9,14 @@ use Illuminate\Support\Facades\DB;
/** Navigation over existing authorisations; never grants a role or publishes content. */ /** Navigation over existing authorisations; never grants a role or publishes content. */
final class ContentManagement final class ContentManagement
{ {
/**
* @return array<array-key, mixed>
*/
public function access(?User $actor): array public function access(?User $actor): array
{ {
if (! $actor || ! $actor->hasVerifiedEmail()) return []; if (! $actor || ! $actor->hasVerifiedEmail()) {
return [];
}
$profiles = app(PortalProfileAccess::class); $profiles = app(PortalProfileAccess::class);
$navigation = $profiles->navigation($actor); $navigation = $profiles->navigation($actor);
$legacy = $profiles->legacyWriteAllowed($actor); $legacy = $profiles->legacyWriteAllowed($actor);
@@ -19,15 +24,23 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor); $editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor); $upload = $editor || $documents->limited($actor);
$result = []; $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)) { if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) {
$result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades', $result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades',
'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy]; '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 $result;
} }
/**
* @return array<array-key, mixed>
*/
public function view(User $actor): array public function view(User $actor): array
{ {
$access = $this->access($actor); $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() $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]) : []; ->mapWithKeys(fn ($row) => [$row->published ? 'published' : 'unpublished' => (int) $row->total]) : [];
} }
return ['access' => $access, 'counts' => $counts]; return ['access' => $access, 'counts' => $counts];
} }
} }
+5 -1
View File
@@ -2,15 +2,19 @@
namespace App\Services; namespace App\Services;
use App\Support\CheckedFile;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
/** Supplement the six named agribusiness sections without changing corridor masters or source documents. */ /** Supplement the six named agribusiness sections without changing corridor masters or source documents. */
final class CorridorAgribusiness final class CorridorAgribusiness
{ {
/**
* @param array<array-key, mixed> $links
*/
public function enrich(string $html, array $links = []): string 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; $doc = new DOMDocument;
$previous = libxml_use_internal_errors(true); $previous = libxml_use_internal_errors(true);
try { try {
@@ -2,9 +2,11 @@
namespace App\Services; namespace App\Services;
use App\Models\InvestmentDossier;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
@@ -14,22 +16,33 @@ final class CorridorAgribusinessOpportunities
{ {
public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055'; public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055';
/** Documentary cross-reference only; keep the two source designations and master records intact. */ /** Documentary cross-reference only; keep the two source designations and master records intact.
public function portfolioOpportunityIds(\App\Models\InvestmentDossier $dossier): array * @return array<array-key, mixed>
*/
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 // 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. // 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'); $keys = collect($this->entries())->where('corridor', 'Corredor da Beira')->pluck('key');
return Opportunity::published()->whereIn('management_creation_key', $keys) return Opportunity::published()->whereIn('management_creation_key', $keys)
->where('workflow_context->corridor_agribusiness->source_sha256', self::SOURCE_HASH)->pluck('id')->all(); ->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.'; 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<array-key, mixed>
*/
public function entries(): array public function entries(): array
{ {
$data = json_decode(file_get_contents(database_path('data/corridor-investment-opportunities.json')), true, 512, JSON_THROW_ON_ERROR); $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.'); 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(); $sector = Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNull('deleted_at')->firstOrFail();
$entries = []; $entries = [];
foreach ($data['groups'] as $g => $group) { foreach ($data['groups'] as $g => $group) {
@@ -42,14 +55,21 @@ final class CorridorAgribusinessOpportunities
'summary' => $item['description'], 'description' => $description, 'sector_id' => $sector->id]; '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 $entries;
} }
/**
* @return array<array-key, mixed>
*/
public function register(User $actor, string $organization): array public function register(User $actor, string $organization): array
{ {
// The caller uses the ordinary profile creation route and its approved scope. // The caller uses the ordinary profile creation route and its approved scope.
abort_unless(str_starts_with(request()->path(), 'minhas-oportunidades'), 422); abort_unless(str_starts_with(request()->path(), 'minhas-oportunidades'), 422);
return DB::transaction(function () use ($actor, $organization) { return DB::transaction(function () use ($actor, $organization) {
DB::select('SELECT pg_advisory_xact_lock(231026, 34)'); DB::select('SELECT pg_advisory_xact_lock(231026, 34)');
$result = []; $result = [];
@@ -60,6 +80,7 @@ final class CorridorAgribusinessOpportunities
abort_unless(data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') === self::SOURCE_HASH, 409); abort_unless(data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') === self::SOURCE_HASH, 409);
// Recovery never overwrites editorial changes, withdrawal or archive. // Recovery never overwrites editorial changes, withdrawal or archive.
$result[] = ['id' => $record->id, 'created' => false, 'key' => $entry['key']]; $result[] = ['id' => $record->id, 'created' => false, 'key' => $entry['key']];
continue; continue;
} }
$record = app(OpportunityManagement::class)->save($actor, ['creation_key' => $entry['key'], 'organization_id' => $organization, $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)]); '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']]; $result[] = ['id' => $record->id, 'created' => true, 'key' => $entry['key']];
} }
return $result; return $result;
}); });
} }
/**
* @return array<array-key, mixed>
*/
public function articleLinks(): 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'); $records = Opportunity::published()->whereIn('management_creation_key', array_column($this->entries(), 'key'))->get()->keyBy('management_creation_key');
$links = []; $links = [];
foreach ($this->entries() as $entry) { 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; return $links;
} }
} }
+2 -2
View File
@@ -15,8 +15,8 @@ final class DeclaredOriginStatistics
abort_unless(app(ScopedPortalAdministration::class)->interestOperator($actor), 403); abort_unless(app(ScopedPortalAdministration::class)->interestOperator($actor), 403);
$names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code'); $names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code');
$groups = []; $groups = [];
$interests=DB::table('interest_requests')->whereNull('deleted_at'); $interests = DB::table('interest_requests')->whereNull('deleted_at');
app(ScopedPortalAdministration::class)->interestQuery($interests,$actor); app(ScopedPortalAdministration::class)->interestQuery($interests, $actor);
foreach ($interests->select('metadata')->cursor() as $row) { foreach ($interests->select('metadata')->cursor() as $row) {
$metadata = json_decode($row->metadata ?? '{}', true); $metadata = json_decode($row->metadata ?? '{}', true);
$code = $metadata['declared_origin_country_code'] ?? null; $code = $metadata['declared_origin_country_code'] ?? null;
+6 -1
View File
@@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -37,6 +38,7 @@ class DocumentManagementAccess
public function editorialResources(User $actor): Builder public function editorialResources(User $actor): Builder
{ {
abort_unless($this->editorial($actor), 403); abort_unless($this->editorial($actor), 403);
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted')
->where(function ($query) use ($actor) { ->where(function ($query) use ($actor) {
@@ -50,7 +52,7 @@ class DocumentManagementAccess
->where('d.external_id', $item['external_id']); ->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. // These exact original bytes contain the legacy identity/source.
// Destination upload IDs differ; the approved file scope does not. // Destination upload IDs differ; the approved file scope does not.
$query->orWhere('d.sha256', $item['sha256']); $query->orWhere('d.sha256', $item['sha256']);
@@ -80,6 +82,9 @@ class DocumentManagementAccess
return $query; return $query;
} }
/**
* @param array<array-key, mixed> $data
*/
public function audit(User $actor, string $action, array $data): void public function audit(User $actor, string $action, array $data): void
{ {
DB::table('audit_events')->insert([ DB::table('audit_events')->insert([
+59 -17
View File
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Support\CheckedFile;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -11,14 +12,19 @@ use Illuminate\Support\Facades\Schema;
class EconomicReferenceCatalog class EconomicReferenceCatalog
{ {
public const LOT = 'lot-01'; public const LOT = 'lot-01';
public const SOURCE = 'economic-reference-lot-01'; public const SOURCE = 'economic-reference-lot-01';
public const MANIFEST_SHA256 = 'ba1bb3406fd55e39c19fd2a2755f3dfc2a1160b67eee8b3b6e12cd43f4223b04'; public const MANIFEST_SHA256 = 'ba1bb3406fd55e39c19fd2a2755f3dfc2a1160b67eee8b3b6e12cd43f4223b04';
/**
* @return array<array-key, mixed>
*/
public function manifest(): array public function manifest(): array
{ {
$file = database_path('data/economic-references-lot-01.json'); $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.'); 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(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); $manifest = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
abort_unless(($manifest['lot'] ?? '') === self::LOT && count($manifest['activities'] ?? []) === 5 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.'); && 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 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(); 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 public function constrain(Builder $query, string $kind): Builder
{ {
$manifest = $this->manifest(); $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()) { if (in_array($kind, ['activity', 'chain'], true) && ! $this->available()) {
$query->whereNotIn('id', array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id')); $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. */ /** A withdrawn published reference can be retained verbatim, never introduced as a new choice. */
public function historicalTarget(string $kind, string $id): bool 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 ($kind === 'activity' && $id === SectorOrganization::LEGACY_ACTIVITY_ID) {
if (! in_array($kind, ['activity', 'chain'], true) || ! $this->schemaAvailable()) { return false; } 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(); $manifest = $this->manifest();
if (! in_array($id, array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'), true)) { return false; } if (! in_array($id, array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'), true)) {
if (! DB::table('audit_events')->where('source_system', self::SOURCE)->where('action', 'publish')->exists()) { return false; } return false;
}
if (! DB::table('audit_events')->where('source_system', self::SOURCE)->where('action', 'publish')->exists()) {
return false;
}
return $kind === 'activity' return $kind === 'activity'
? DB::table('economic_activities')->where('id', $id)->where('status', 'published')->exists() ? DB::table('economic_activities')->where('id', $id)->where('status', 'published')->exists()
: DB::table('value_chains')->where('id', $id)->whereNull('deleted_at')->exists(); : DB::table('value_chains')->where('id', $id)->whereNull('deleted_at')->exists();
} }
/**
* @param array<array-key, mixed> $historicalIds
* @return Collection<int, \stdClass>
*/
public function activities(array $historicalIds = []): Collection public function activities(array $historicalIds = []): Collection
{ {
$rows = DB::table('economic_activities')->where('status', 'published')->get(); $rows = DB::table('economic_activities')->where('status', 'published')->get();
@@ -79,14 +101,19 @@ class EconomicReferenceCatalog
foreach ($historicalIds as $id) { foreach ($historicalIds as $id) {
$seen = []; $seen = [];
while (isset($byId[$id]) && ! isset($seen[$id])) { 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(); $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) : []; $meta = property_exists($row, 'metadata') ? json_decode($row->metadata, true) : [];
$path = []; $current = $row; $seen = []; $path = [];
$current = $row;
$seen = [];
while ($current && ! isset($seen[$current->id])) { while ($current && ! isset($seen[$current->id])) {
$seen[$current->id] = true; $seen[$current->id] = true;
array_unshift($path, ['id' => $current->id, 'code' => $current->code, 'label' => $current->label_pt_mz]); 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; $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, '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, '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, 'source_version' => $meta['source_version'] ?? null, 'edition' => $meta['edition'] ?? null,
'historical_only' => $isLot && ! $available]; 'historical_only' => $isLot && ! $available];
$result = new \stdClass;
foreach ($option as $key => $value) {
$result->{$key} = $value;
}
return $result;
})->values(); })->values();
} }
public function validHierarchy(int $sectorId, ?string $subsectorId, ?string $activityId, bool $historical = false): bool 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(); $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(); $subsector = DB::table('economic_activities')->where('id', $subsectorId)->where('status', 'published')->first();
$activity = DB::table('economic_activities')->where('id', $activityId)->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(); $manifest = $this->manifest();
if ($activity->catalog_version_id !== $manifest['catalog']['id']) { 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; $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; return $root && (int) $root->sector_id === $sectorId && $activity->parent_id === $subsector->id;
} }
if ((! $this->available() && ! $historical) || $sectorId !== (int) $manifest['existing']['sector']['id'] if ((! $this->available() && ! $historical) || $sectorId !== (int) $manifest['existing']['sector']['id']
|| $subsector->code !== '01' || $activity->code !== '01120') { return false; } || $subsector->code !== '01' || $activity->code !== '01120') {
$current = $activity; $codes = []; $seen = []; return false;
}
$current = $activity;
$codes = [];
$seen = [];
while ($current && ! isset($seen[$current->id])) { 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; $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;
} }
+192 -61
View File
@@ -4,19 +4,22 @@ namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Services\Malware\MalwareScanner; use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
/** Bounded authenticated extension. No generic seeding, arbitrary path or caller-supplied actor. */ /** Bounded authenticated extension. No generic seeding, arbitrary path or caller-supplied actor. */
class EconomicReferenceInstallation class EconomicReferenceInstallation
{ {
public const MIGRATION = '2026_10_16_120000_extend_economic_reference_hierarchy'; public const MIGRATION = '2026_10_16_120000_extend_economic_reference_hierarchy';
public const OPERATIONS = ['schema', 'import', 'publish', 'withdraw']; public const OPERATIONS = ['schema', 'import', 'publish', 'withdraw'];
/**
* @return array<array-key, mixed>
*/
public function files(): array public function files(): array
{ {
return ['app/Services/EconomicReferenceCatalog.php', 'app/Services/EconomicReferenceInstallation.php', 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)); 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<array-key, mixed>
*/
private function target(): array private function target(): array
{ {
return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
} }
/**
* @param array<array-key, mixed> $target
*/
private function qa(array $target): bool private function qa(array $target): bool
{ {
return app()->environment('testing') && str_ends_with($target['database'], '_test') && (int) $target['port'] === 55441 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 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.'); 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); $access = app(PortalProfileAccess::class);
abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.'); 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.'); 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.'); abort_unless(! app()->environment('testing') || $this->qa($this->target()), 403, 'Alvo QA isolado inválido.');
} }
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code, string $operation): array private function evidence(array $target, string $code, string $operation): array
{ {
if ($this->qa($target)) { return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null]; } if ($this->qa($target)) {
if (app()->environment('testing')) { return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null]; } 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'; $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'] ?? ''); $date = strtotime($proof['verified_at'] ?? '');
$permission = $proof['active_authorization'] ?? []; $permission = $proof['active_authorization'] ?? [];
$ready = ! config('database.connections.pgsql.url') && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true) $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) && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& str_ends_with($proof['restore_target']['database'] ?? '', '_test') && (int) ($proof['restore_target']['port'] ?? 0) === 55441 && 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'] ?? '') && is_file($proof['dump_path'] ?? '') && is_file($proof['files_path'] ?? '')
&& hash_equals($proof['dump_sha256'] ?? '', hash_file('sha256', $proof['dump_path'])) && hash_equals($proof['dump_sha256'] ?? '', CheckedFile::sha256($proof['dump_path']))
&& hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path'])) && hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))
&& ($permission['decision_record'] ?? '') !== '' && ($permission['code_hash'] ?? '') === $code && ($permission['decision_record'] ?? '') !== '' && ($permission['code_hash'] ?? '') === $code
&& ($permission['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256 && ($permission['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256
&& in_array($operation, $permission['operations'] ?? [], true); && in_array($operation, $permission['operations'] ?? [], true);
@@ -83,25 +107,35 @@ class EconomicReferenceInstallation
'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null]; '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<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function sources(array $manifest): array private function sources(array $manifest): array
{ {
$root = realpath($this->directory()); $sources = []; $root = realpath($this->directory());
$sources = [];
foreach ($manifest['sources'] as $entry) { foreach ($manifest['sources'] as $entry) {
$path = $root ? realpath($root.DIRECTORY_SEPARATOR.$entry['relative_path']) : false; $path = $root ? realpath($root.DIRECTORY_SEPARATOR.$entry['relative_path']) : false;
$inside = $path && str_starts_with(strtolower(str_replace('\\', '/', $path)), strtolower(str_replace('\\', '/', $root)).'/'); $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]; $sources[] = ['name' => $entry['name'], 'sha256' => $entry['sha256'], 'bytes' => $entry['bytes'], 'ready' => (bool) $matches];
} }
return $sources; return $sources;
} }
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function referenceRows(array $manifest): array private function referenceRows(array $manifest): array
{ {
$rows = [['table' => 'reference_catalog_versions', 'data' => $manifest['catalog']]]; $rows = [['table' => 'reference_catalog_versions', 'data' => $manifest['catalog']]];
foreach (['activities' => 'economic_activities', 'chains' => 'value_chains', 'links' => 'value_chain_links'] as $group => $table) { 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; return $rows;
@@ -113,41 +147,67 @@ class EconomicReferenceInstallation
->whereRaw("after_data->'result'->'changed' @> ?::jsonb", [json_encode([$id])])->exists(); ->whereRaw("after_data->'result'->'changed' @> ?::jsonb", [json_encode([$id])])->exists();
} }
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function references(array $manifest, bool $schemaReady): array private function references(array $manifest, bool $schemaReady): array
{ {
$results = []; $results = [];
foreach ($this->referenceRows($manifest) as $row) { 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(); $current = DB::table($table)->where('id', $expected['id'])->first();
$conflict = false; $conflict = false;
foreach ($expected as $key => $value) { foreach ($expected as $key => $value) {
if (! $current || ($key === 'metadata' && ! $schemaReady)) { continue; } if (! $current || ($key === 'metadata' && ! $schemaReady)) {
if ($key === 'status') { $conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true); continue; } continue;
if ($key === 'data_classification') { $conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true); 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; $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']); $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']); } if ($table === 'reference_catalog_versions') {
elseif ($table === 'economic_activities') { $identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']); } $identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']);
else { $identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']); } } 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(); $conflict = $conflict || $identity->exists();
if ($table === 'value_chains') { 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(); $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'], $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, '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 $results;
} }
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array public function preview(User $actor): array
{ {
$this->authorize($actor); $this->authorize($actor);
$catalog = app(EconomicReferenceCatalog::class); $manifest = $catalog->manifest(); $catalog = app(EconomicReferenceCatalog::class);
$target = $this->target(); $code = $this->fingerprint(); $manifest = $catalog->manifest();
$target = $this->target();
$code = $this->fingerprint();
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists(); $ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$column = $catalog->schemaAvailable(); $column = $catalog->schemaAvailable();
$unique = DB::selectOne("SELECT 1 FROM pg_constraint WHERE conrelid='public.economic_activities'::regclass AND conname='economic_activities_sector_uq'") !== null; $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; $index = DB::selectOne("SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='economic_activities_sector_idx'") !== null;
$installed = $ledger && $column && ! $unique && $sectorNullable && $index; $installed = $ledger && $column && ! $unique && $sectorNullable && $index;
$consistent = $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) { 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) { 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 (! $consistent) {
if (collect($rows)->contains('status', 'conflict')) { $reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.'; } $reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.';
if (collect($sources)->contains('ready', false)) { $reasons[] = 'Exemplar privado ausente ou com bytes diferentes.'; } }
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'); $imported = ! collect($rows)->contains(fn ($r) => $r['status'] !== 'reuse');
$scanPath = $this->directory().'/scans.json'; $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, $state = ['target' => $target, 'code_hash' => $code, 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256,
'migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'imported' => $imported, 'migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'imported' => $imported,
'publication' => $catalog->publicationState(), 'rows' => $rows, 'sources' => $sources, 'scan' => $scan, 'publication' => $catalog->publicationState(), 'rows' => $rows, 'sources' => $sources, 'scan' => $scan,
@@ -175,15 +247,27 @@ class EconomicReferenceInstallation
$operations = []; $operations = [];
foreach (self::OPERATIONS as $operation) { foreach (self::OPERATIONS as $operation) {
$why = $reasons; $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); $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 (! $evidence['ready']) {
if ($operation !== 'schema' && ! $installed) { $why[] = 'Instale primeiro o esquema nominal.'; } $why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.';
if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) { $why[] = 'Importação privada completa necessária antes de publicar/retirar.'; } }
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()) { 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.'; $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' if ($operation === 'withdraw' && collect($rows)->contains(fn ($r) => $r['status'] === 'reuse'
&& in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id']))) { && 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.'; $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]; return [...$state, 'operations' => $operations];
} }
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function scan(array $manifest): array private function scan(array $manifest): array
{ {
$directory = realpath($this->directory()); $records = []; $directory = realpath($this->directory());
abort_unless($directory, 422, 'Área privada do lote indisponível.'); $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]]; $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) { 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. // Scanner's path confinement uses the private source directory; manifest is scanned via a private exact copy.
$path = $file['path']; $path = $file['path'];
if ($file['name'] === 'manifesto') { $path = $directory.'/manifest-scanned.json'; file_put_contents($path, file_get_contents($file['path'])); } if ($file['name'] === 'manifesto') {
$result = app(MalwareScanner::class)->scan($path, filesize($path), $directory); $path = $directory.'/manifest-scanned.json';
if ($result['signatures_at'] instanceof \DateTimeInterface) { $result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM); } 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]; $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($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]; $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)); 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; return $proof;
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, string $operation, array $input): array public function apply(User $actor, string $operation, array $input): array
{ {
abort_unless(in_array($operation, self::OPERATIONS, true), 404); abort_unless(in_array($operation, self::OPERATIONS, true), 404);
$this->authorize($actor, true, $operation); $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(); $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) { 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()]; 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(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'])); abort_unless($phase['ready'], 422, implode(' ', $phase['reasons']));
$manifest = app(EconomicReferenceCatalog::class)->manifest(); $changed = []; $reused = []; $manifest = app(EconomicReferenceCatalog::class)->manifest();
foreach ($this->referenceRows($manifest) as $row) { DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first(); } $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('sectors')->where('id', $manifest['existing']['sector']['id'])->lockForUpdate()->first();
DB::table('economic_clusters')->where('id', $manifest['existing']['cluster']['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. // 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.'); 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']) { if ($operation === 'schema' && ! $plan['installed']) {
config(['economic_references.authenticated_installation' => true]); 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.'); } try {
finally { config(['economic_references.authenticated_installation' => false]); } 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.'); abort_unless($this->preview($actor)['installed'], 500, 'Esquema instalado não corresponde ao plano.');
$changed[] = self::MIGRATION; $changed[] = self::MIGRATION;
} elseif ($operation === 'import') { } elseif ($operation === 'import') {
$scan = $this->scan($manifest); $scan = $this->scan($manifest);
foreach ($this->referenceRows($manifest) as $i => $row) { foreach ($this->referenceRows($manifest) as $i => $row) {
if ($plan['rows'][$i]['status'] === 'reuse') { $reused[] = $row['data']['id']; continue; } if ($plan['rows'][$i]['status'] === 'reuse') {
$values = $row['data']; if (isset($values['metadata'])) { $values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR); } $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()]); DB::table($row['table'])->insert([...$values, 'created_at' => now(), 'updated_at' => now()]);
$changed[] = $values['id']; $changed[] = $values['id'];
} }
} elseif ($operation === 'publish' || $operation === 'withdraw') { } 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) { 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 (in_array($table, ['reference_catalog_versions', 'economic_activities'], true)) {
if ($operation === 'publish' && $record->status === 'draft') { if ($operation === 'publish' && $record->status === 'draft') {
DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]); $changed[] = $record->id; DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]);
} else { $reused[] = $record->id; } $changed[] = $record->id;
} else {
$reused[] = $record->id;
}
} else { } else {
$visibility = $operation === 'publish' ? 'public' : 'internal'; $visibility = $operation === 'publish' ? 'public' : 'internal';
// Withdrawal touches only objects this importer created, never pre-existing public references. // Withdrawal touches only objects this importer created, never pre-existing public references.
$createdHere = $this->createdHere($record->id); $createdHere = $this->createdHere($record->id);
if ($operation === 'withdraw' && ! $createdHere) { $reused[] = $record->id; continue; } if ($operation === 'withdraw' && ! $createdHere) {
if ($record->data_classification !== $visibility) { DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]); $changed[] = $record->id; } $reused[] = $record->id;
else { $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, '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, 'publications' => $operation === 'publish' ? 'references only; no investment or original source publication' : 'none', 'business_records_changed' => 0,
'receipt' => $data['idempotency_key'], 'scan' => $scan ?? null]; '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'], 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', '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)]); 'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
+118 -52
View File
@@ -3,8 +3,10 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException; 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. */ /** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class EditorialInstallation class EditorialInstallation
{ {
public const SOURCE='editorial-schema-installation'; public const SOURCE = 'editorial-schema-installation';
public const MIGRATION='2026_10_18_160000_add_portal_editorial_content';
public const MIGRATION = '2026_10_18_160000_add_portal_editorial_content';
/**
* @return array<array-key, mixed>
*/
public function files(): 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']; 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 public function fingerprint(): string
{ {
$files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files()); $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())];} foreach (File::allFiles(public_path('build')) as $file) {
usort($files,fn($a,$b)=>strcmp($a[0],$b[0])); $files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR)); }
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.'); abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403, 'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
} }
private function evidence(array $target,string $code): array
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code): array
{ {
$qa=app()->environment('testing') && (int)$target['port']===55441 && str_ends_with($target['database'],'_test') $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'); && config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];} if ($qa) {
if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];} return ['ready' => true, 'mode' => 'isolated_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'] ?? []; if (app()->environment('testing')) {
$ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url') return ['ready' => false, 'mode' => 'invalid_QA'];
&& 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'] $path = storage_path('app/private/investmoz-v2/editorial/readiness.json');
&& ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400 $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['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') && ($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; && ($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));} foreach (['dump', 'files'] as $kind) {
return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null]; $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<array-key, mixed>
*/
public function preview(User $actor): 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(); $this->authorize($actor);
$ledgerCount=DB::table('migrations')->where('migration',self::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('portal_resources','editorial_content'); $target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$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'"); $code = $this->fingerprint();
$correct=$shape && $shape->data_type==='jsonb' && $shape->is_nullable==='YES'; $ledgerCount = DB::table('migrations')->where('migration', self::MIGRATION)->count();
$consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[]; $ledger = $ledgerCount === 1;
if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';} $column = Schema::hasColumn('portal_resources', 'editorial_content');
try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();} $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'");
$evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';} $correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES';
$state=['target'=>$target,'migration'=>self::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column, $consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence]; $reasons = [];
return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))]; 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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{ {
$this->authorize($actor,true); $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.'); 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(); $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)); return DB::transaction(function () use ($actor, $data) {
$old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first(); DB::select('SELECT pg_advisory_xact_lock(181026,160000)');
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];} $requestHash = hash('sha256', json_encode($data, 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'])); $old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if(!$plan['installed']){ if ($old) {
config(['editorial.authenticated_installation'=>true]); $receipt = json_decode($old->after_data, true);
try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.self::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');} 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.');
finally{config(['editorial.authenticated_installation'=>false]);}
return [...$receipt['result'], 'replayed' => true];
} }
abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.'); $plan = $this->preview($actor);
$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']]; abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'portal-editorial-schema','entity_id'=>null,'actor_user_id'=>$actor->id, abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]); 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; return $result;
}); });
} }
+20 -7
View File
@@ -2,8 +2,10 @@
namespace App\Services; namespace App\Services;
use App\Support\CheckedFile;
use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException; 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. */ /** Reads verified originals without writing a derived edition or changing publication. */
final class EditorialReading 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') 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') ->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'); ->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
} }
public function original(object $resource): ?array /**
* @return array<array-key, mixed>|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)) { if (! $document || $document->status !== 'active' || $document->malware_scan_status !== 'clean' || ! in_array($document->media_type, ['text/plain', 'application/json'], true)) {
return null; return null;
} }
@@ -40,7 +45,11 @@ final class EditorialReading
return trim(html_entity_decode(strip_tags(preg_replace('~</(?:p|div|li|h[1-6]|tr)>|<br\s*/?>~i', "\n\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')); return trim(html_entity_decode(strip_tags(preg_replace('~</(?:p|div|li|h[1-6]|tr)>|<br\s*/?>~i', "\n\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
} }
public function content(object $resource, ?array $original): array /**
* @param array<array-key, mixed>|null $original
* @return array<array-key, mixed>
*/
public function content(\stdClass $resource, ?array $original): array
{ {
return $resource->editorial_content ? json_decode($resource->editorial_content, true, 512, JSON_THROW_ON_ERROR) 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]; : ['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<array-key, mixed>|null $override
* @return array<array-key, mixed>
*/
public function reading(\stdClass $resource, ?array $override = null, bool $private = false): array
{ {
$original = $this->original($resource); $original = $this->original($resource);
$content = $override ?? $this->content($resource, $original); $content = $override ?? $this->content($resource, $original);
abort_unless(trim($content['body'] ?? '') !== '', 404, 'Este documento não contém um artigo legível.'); 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. // The original is verified above; ordinary text adaptations retain the binary gate too.
app(VerifiedDocumentBinary::class)->respond($document); app(VerifiedDocumentBinary::class)->respond($document);
$mode = $content['image_mode'] ?? (! empty($content['image_resource_id']) ? 'selected' : 'none'); $mode = $content['image_mode'] ?? (! empty($content['image_resource_id']) ? 'selected' : 'none');
$originalImageUrl = $this->sourceUrl($original['image_source_url'] ?? null, $original['source_url'] ?? null); $originalImageUrl = $this->sourceUrl($original['image_source_url'] ?? null, $original['source_url'] ?? null);
$expectedImageSha = $original['image_sha256'] ?? null; $expectedImageSha = $original['image_sha256'] ?? null;
if (! $expectedImageSha && $originalImageUrl === 'https://investemoz.tempo.co.mz/images/tempo-background.png') { 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) { if ($accepted['sha256'] === $document->sha256) {
$expectedImageSha = $accepted['original_image_sha256'] ?? null; $expectedImageSha = $accepted['original_image_sha256'] ?? null;
break; break;
+105 -26
View File
@@ -6,6 +6,7 @@ use App\Models\ExistingBusiness;
use App\Models\User; use App\Models\User;
use App\Policies\ExistingBusinessPolicy; use App\Policies\ExistingBusinessPolicy;
use App\Services\Mne\BusinessRepository; use App\Services\Mne\BusinessRepository;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -19,8 +20,12 @@ use Illuminate\Validation\ValidationException;
class ExistingBusinessClassification class ExistingBusinessClassification
{ {
public const VERSION = 'business-classification-20261016-v1'; public const VERSION = 'business-classification-20261016-v1';
private const ACTIONS = ['classification_prepared', 'classification_reviewed', 'classification_disclosed', 'classification_withdrawn']; private const ACTIONS = ['classification_prepared', 'classification_reviewed', 'classification_disclosed', 'classification_withdrawn'];
/**
* @return array<array-key, mixed>
*/
public function context(ExistingBusiness $business): 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']); $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; return $state;
} }
/**
* @param array<array-key, mixed> $row
*/
private function hash(array $row): string private function hash(array $row): string
{ {
return OpportunityCanonicalValue::hash($row); return OpportunityCanonicalValue::hash($row);
} }
/**
* @param array<array-key, mixed> $row
* @return array<array-key, mixed>|null
*/
private function evidence(array $row, ExistingBusiness $business): ?array private function evidence(array $row, ExistingBusiness $business): ?array
{ {
$id = $row['classification']['document_id'] ?? null; $id = $row['classification']['document_id'] ?? null;
@@ -51,6 +63,9 @@ class ExistingBusinessClassification
return $document ? (array) $document : ['unavailable' => $id]; return $document ? (array) $document : ['unavailable' => $id];
} }
/**
* @param array<array-key, mixed> $row
*/
private function evidenceHash(array $row, ExistingBusiness $business): ?string private function evidenceHash(array $row, ExistingBusiness $business): ?string
{ {
$evidence = $this->evidence($row, $business); $evidence = $this->evidence($row, $business);
@@ -58,6 +73,9 @@ class ExistingBusinessClassification
return $evidence === null ? null : OpportunityCanonicalValue::hash($evidence); return $evidence === null ? null : OpportunityCanonicalValue::hash($evidence);
} }
/**
* @param array<array-key, mixed> $rows
*/
private function validateRows(array $rows, ExistingBusiness $business, bool $confirming = false): void private function validateRows(array $rows, ExistingBusiness $business, bool $confirming = false): void
{ {
$seen = []; $seen = [];
@@ -80,7 +98,8 @@ class ExistingBusinessClassification
if (array_diff(array_keys($row), ['related_id', 'relation_type', 'classification'])) { 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.'); $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']))) { if (($kind === 'sector' && ! ctype_digit($row['related_id'])) || ($kind !== 'sector' && ! Str::isUuid($row['related_id']))) {
$fail('related_id', 'Identificador de referência inválido.'); $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.'); $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']; $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; $seen[$identity] = true;
if (! empty($c['valid_from']) && ! empty($c['valid_until']) && $c['valid_until'] < $c['valid_from']) { 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.'); $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.'); $fail('classification.authority_id', 'Entidade competente não consta do catálogo publicado.');
} }
$evidence = $this->evidence($row, $business); $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'] ?? '')))) { 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.'); $fail('classification.external_act_reference', 'Um ato declarado como concedido exige documento e referência próprios.');
} }
if ($confirming) { if ($confirming) {
foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) { 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' 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'])) || ! hash_equals(trim($evidence['sha256']), trim((string) $evidence['malware_scanned_sha256']))
@@ -117,13 +142,21 @@ class ExistingBusinessClassification
} }
} }
/**
* @param array<array-key, mixed> $row
* @param array<array-key, mixed> $state
*/
private function effective(ExistingBusiness $business, array $row, array $state): string private function effective(ExistingBusiness $business, array $row, array $state): string
{ {
$decision = $state['decisions'][$this->hash($row)] ?? null; $decision = $state['decisions'][$this->hash($row)] ?? null;
if (! $decision) { return $row['classification']['requested_state'] ?? 'preliminary'; } if (! $decision) {
if ($decision['state'] !== 'confirmed') { return $decision['state']; } return $row['classification']['requested_state'] ?? 'preliminary';
}
if ($decision['state'] !== 'confirmed') {
return $decision['state'];
}
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; $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) if (! $reviewer || ! app(ExistingBusinessPolicy::class)->classificationReviewerScope($reviewer, $business, $dimension)
|| (int) ($state['authors'][$this->hash($row)] ?? 0) === (int) $reviewer->id || (int) ($state['authors'][$this->hash($row)] ?? 0) === (int) $reviewer->id
|| ($decision['evidence_hash'] ?? null) !== $this->evidenceHash($row, $business)) { || ($decision['evidence_hash'] ?? null) !== $this->evidenceHash($row, $business)) {
@@ -133,13 +166,17 @@ class ExistingBusinessClassification
return 'confirmed'; return 'confirmed';
} }
/**
* @return array<array-key, mixed>
*/
public function privateView(User $actor, ExistingBusiness $business): array public function privateView(User $actor, ExistingBusiness $business): array
{ {
Gate::forUser($actor)->authorize('view', $business); Gate::forUser($actor)->authorize('view', $business);
$state = $this->context($business); $state = $this->context($business);
$rows = []; $rows = [];
foreach ($state['relations'] as $row) { 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), $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) 'can_review' => Gate::forUser($actor)->allows('reviewClassification', $business) && app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension)
&& (int) ($state['authors'][$hash] ?? 0) !== (int) $actor->id, && (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]])]; 'documents' => $documents->map(fn ($d) => ['id' => $d->id, 'payload' => ['title' => $d->file_name], 'file' => ['file_name' => $d->file_name]])];
} }
/**
* @param array<array-key, mixed> $input
*/
public function act(User $actor, string $id, array $input): ExistingBusiness public function act(User $actor, string $id, array $input): ExistingBusiness
{ {
$data = Validator::make($input, [ $data = Validator::make($input, [
@@ -167,26 +207,32 @@ class ExistingBusinessClassification
'hash' => ['required_if:action,review', 'string', 'size:64'], 'hash' => ['required_if:action,review', 'string', 'size:64'],
'state' => ['required_if:action,review', Rule::in(['in_validation', 'confirmed', 'rejected', 'archived', 'revoked'])], '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(); ], ['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) { return DB::transaction(function () use ($actor, $id, $data) {
$business = app(BusinessRepository::class)->find($id, true); $business = app(BusinessRepository::class)->find($id, true);
$action = $data['action']; $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.'); 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); $state = $this->context($business);
if ($action === 'prepare') { if ($action === 'prepare') {
$this->validateRows($data['rows'], $business); $this->validateRows($data['rows'], $business);
$state['relations'] = $data['rows']; $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') { } elseif ($action === 'review') {
$search = in_array($data['state'], ['revoked', 'archived'], true) ? [...$state['relations'], ...$state['publication']] : $state['relations']; $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.'); abort_unless($row, 409, 'Associação inexistente nesta versão.');
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
abort_unless(app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension) 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.'); && (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']] ?? []; $prior = $state['decisions'][$data['hash']] ?? [];
$decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'], $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]; 'evidence_hash' => $this->evidenceHash($row, $business), 'version' => ($prior['version'] ?? 0) + 1];
@@ -216,21 +262,35 @@ class ExistingBusinessClassification
}); });
} }
/**
* @param array<array-key, mixed>|null $preparedState
* @return array<array-key, mixed>
*/
public function publicRows(ExistingBusiness $business, ?array $preparedState = null): array public function publicRows(ExistingBusiness $business, ?array $preparedState = null): array
{ {
if ($business->trashed() || $business->status !== 'published' || ! $business->published_at || $business->published_at->isFuture() if ($business->trashed() || $business->status !== 'published' || ! $business->published_at || $business->published_at->isFuture()
|| ($business->expires_at && $business->expires_at->isBefore(today()))) { return []; } || ($business->expires_at && $business->expires_at->isBefore(today()))) {
$state = $preparedState ?? $this->context($business); $rows = []; return [];
}
$state = $preparedState ?? $this->context($business);
$rows = [];
foreach ($state['publication'] as $row) { foreach ($state['publication'] as $row) {
$c = $row['classification']; $kind = $c['target_type']; $c = $row['classification'];
if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) { continue; } $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(); $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); $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'], $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, '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, 'state' => $effective, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null,
'authority_id' => $c['authority_id'] ?? null, 'production_stage' => $c['production_stage'] ?? 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]; '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 $rows;
} }
/**
* @return array<array-key, mixed>
*/
public function filterRules(): array public function filterRules(): array
{ {
return ['association_type' => ['nullable', Rule::in(array_keys(OpportunityClassification::TARGETS))], 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']]; 'chain_id' => ['nullable', 'uuid'], 'authority_id' => ['nullable', 'uuid'], 'subsector_id' => ['nullable', 'uuid']];
} }
/**
* @param array<array-key, mixed> $filters
* @param Builder<ExistingBusiness> $query
*/
public function constrain(Builder $query, array $filters): void public function constrain(Builder $query, array $filters): void
{ {
$filters = array_filter(array_intersect_key($filters, $this->filterRules()), fn ($v) => $v !== null && $v !== ''); $filters = array_filter(array_intersect_key($filters, $this->filterRules()), fn ($v) => $v !== null && $v !== '');
if ($filters === []) { return; } if ($filters === []) {
return;
}
$ids = []; $ids = [];
(clone $query)->reorder()->select('existing_businesses.*')->chunkById(200, function ($records) use ($filters, &$ids) { (clone $query)->reorder()->select('existing_businesses.*')->chunkById(200, function ($records) use ($filters, &$ids) {
foreach ($records as $business) { 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) { $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) { 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; return true;
}); });
foreach (['chain_id' => 'chain', 'subsector_id' => 'activity'] as $key => $type) { 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'); }, 'existing_businesses.id', 'id');
$query->whereIn('existing_businesses.id', $ids); $query->whereIn('existing_businesses.id', $ids);
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Services; namespace App\Services;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
@@ -9,28 +10,28 @@ final class ExistingBusinessProfileCategory
// Exact catalogue correspondences plus the explicit mappings already present // Exact catalogue correspondences plus the explicit mappings already present
// in BusinessWizard and the transaction variants in OpportunityCategoryFields. // in BusinessWizard and the transaction variants in OpportunityCategoryFields.
public const MAP = [ public const MAP = [
'company_sale'=>'business_sale', 'participation_sale'=>'business_sale', 'company_sale' => 'business_sale', 'participation_sale' => 'business_sale',
'buyer_search'=>'business_sale', 'succession'=>'business_sale', 'buyer_search' => 'business_sale', 'succession' => 'business_sale',
'franchise'=>'franchise', 'real_estate'=>'real_estate', 'land_right'=>'land_rights', 'franchise' => 'franchise', 'real_estate' => 'real_estate', 'land_right' => 'land_rights',
'investment_project'=>'investment_project', 'expansion'=>'investment_project', 'investment_project' => 'investment_project', 'expansion' => 'investment_project',
'internationalization'=>'investment_project', 'agricultural_investment'=>'investment_project', 'internationalization' => 'investment_project', 'agricultural_investment' => 'investment_project',
'business_partnership'=>'business_partnership', 'strategic_partner'=>'business_partnership', 'business_partnership' => 'business_partnership', 'strategic_partner' => 'business_partnership',
'joint_venture'=>'business_partnership', 'capital_increase'=>'financing', 'joint_venture' => 'business_partnership', 'capital_increase' => 'financing',
'financial_investor'=>'financing', 'financing_request'=>'financing', 'financial_investor' => 'financing', 'financing_request' => 'financing',
'concession_opportunity'=>'concession', 'supply_opportunity'=>'supply', 'concession_opportunity' => 'concession', 'supply_opportunity' => 'supply',
]; ];
// Native form types are separate from the persisted modality. No default to a project. // Native form types are separate from the persisted modality. No default to a project.
public const FORM_TYPES = [ public const FORM_TYPES = [
'investment_project'=>'investment_project', 'company_sale'=>'company_sale', 'investment_project' => 'investment_project', 'company_sale' => 'company_sale',
'asset_sale'=>'asset_sale', 'business_partnership'=>'business_partnership', 'asset_sale' => 'asset_sale', 'business_partnership' => 'business_partnership',
'financing_request'=>'financing_request', 'concession_opportunity'=>'concession_opportunity', 'financing_request' => 'financing_request', 'concession_opportunity' => 'concession_opportunity',
'supply_opportunity'=>'supply_opportunity', 'participation_sale'=>'company_sale', 'supply_opportunity' => 'supply_opportunity', 'participation_sale' => 'company_sale',
'asset_lease'=>'asset_sale', 'capital_increase'=>'financing_request', 'asset_lease' => 'asset_sale', 'capital_increase' => 'financing_request',
'financial_investor'=>'financing_request', 'strategic_partner'=>'business_partnership', 'financial_investor' => 'financing_request', 'strategic_partner' => 'business_partnership',
'joint_venture'=>'business_partnership', 'expansion'=>'investment_project', 'joint_venture' => 'business_partnership', 'expansion' => 'investment_project',
'agricultural_investment'=>'investment_project', 'internationalization'=>'investment_project', 'agricultural_investment' => 'investment_project', 'internationalization' => 'investment_project',
'buyer_search'=>'company_sale', 'succession'=>'company_sale', 'buyer_search' => 'company_sale', 'succession' => 'company_sale',
]; ];
public static function category(ExistingBusiness $business): ?string public static function category(ExistingBusiness $business): ?string
+21 -1
View File
@@ -38,6 +38,9 @@ final class FintechInvestment
public function __construct(private readonly VirtualDesk $desk) {} public function __construct(private readonly VirtualDesk $desk) {}
/**
* @param array<array-key, mixed> $input
*/
public function save(User $actor, string $id, array $input): void public function save(User $actor, string $id, array $input): void
{ {
$keys = implode(',', array_keys(self::FIELDS)); $keys = implode(',', array_keys(self::FIELDS));
@@ -89,6 +92,9 @@ final class FintechInvestment
}); });
} }
/**
* @param array<array-key, mixed> $value
*/
private function fingerprint(array $value): string private function fingerprint(array $value): string
{ {
$canonical = function ($v) use (&$canonical) { $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)); return hash('sha256', json_encode($canonical($value), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
} }
/**
* @param array<array-key, mixed> $record
* @return array<array-key, mixed>
*/
public function reviewFingerprints(array $record): array public function reviewFingerprints(array $record): array
{ {
$profile = $record['profile'] ?? []; $profile = $record['profile'] ?? [];
@@ -121,6 +131,11 @@ final class FintechInvestment
]; ];
} }
/**
* @param array<array-key, mixed> $payload
* @param array<array-key, mixed> $steps
* @return array<array-key, mixed>
*/
public function adjustSteps(array $payload, array $steps): array public function adjustSteps(array $payload, array $steps): array
{ {
$record = $payload['investor_path']['fintech'] ?? null; $record = $payload['investor_path']['fintech'] ?? null;
@@ -145,6 +160,11 @@ final class FintechInvestment
return $steps; return $steps;
} }
/**
* @param array<array-key, mixed> $payload
* @param array<array-key, mixed> $steps
* @return array<array-key, mixed>
*/
public function view(array $payload, array $steps): array public function view(array $payload, array $steps): array
{ {
$record = $payload['investor_path']['fintech'] ?? null; $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(), 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) : [], '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.']]; '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.']];
} }
} }
+1 -1
View File
@@ -41,7 +41,7 @@ final class InterestWorkflow
return DB::transaction(function () use ($actor, $interest, $status, $note, $nextAction, $dueAt, $assigneeId, $assignmentProvided, $actionProvided) { return DB::transaction(function () use ($actor, $interest, $status, $note, $nextAction, $dueAt, $assigneeId, $assignmentProvided, $actionProvided) {
$locked = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id); $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; $from = $locked->status;
$actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null; $actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null;
$followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction $followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction
+11 -4
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier; use App\Models\InvestmentDossier;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -239,7 +240,8 @@ class InvestmentCatalogue
}; };
} }
private function declaredProvince($query, string $name): void /** @param Builder<Opportunity> $query */
private function declaredProvince(Builder $query, string $name): void
{ {
// Read the published province declaration only when no more precise territory contradicts it. // 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. // 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))])); ->whereRaw('lower(btrim(opportunities.province)) = ?', [mb_strtolower(trim($name))]));
} }
private function approvedCorridorProvince($query, string $provinceId): void /** @param Builder<Opportunity> $query */
private function approvedCorridorProvince(Builder $query, string $provinceId): void
{ {
$code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code'); $code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code');
$names = []; $names = [];
@@ -344,8 +347,8 @@ class InvestmentCatalogue
// A chain's documented scope comes from its public cluster, never // A chain's documented scope comes from its public cluster, never
// from the opportunities surviving the current search. // from the opportunities surviving the current search.
$ids = app(OpportunityClassification::class)->targets('chain') $ids = app(OpportunityClassification::class)->targets('chain')
->whereIn('cluster_id', $result['clusters']->pluck('id'))->pluck('id'); ->whereIn('cluster_id', ($result['clusters'] ?? collect())->pluck('id'))->pluck('id');
$result['classification']['targets']['chain'] = collect($result['classification']['targets']['chain'])->whereIn('id', $ids)->values(); $result['classification']['targets']['chain'] = collect(StructuredData::collectionItems($result['classification']['targets']['chain']))->whereIn('id', $ids)->values();
} }
// Activities are a public classification catalogue, not evidence of // Activities are a public classification catalogue, not evidence of
// investment availability in a territory or sector. // investment availability in a territory or sector.
@@ -353,6 +356,10 @@ class InvestmentCatalogue
return $result; return $result;
} }
/**
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
*/
private function publicAssociatedIds(array $filters): array private function publicAssociatedIds(array $filters): array
{ {
$kinds = ['park_id' => ['park'], 'zee_id' => ['zone'], 'zfi_id' => ['zone'], $kinds = ['park_id' => ['park'], 'zee_id' => ['zone'], 'zfi_id' => ['zone'],
+87 -40
View File
@@ -4,9 +4,11 @@ namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController; use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity; use App\Models\Opportunity;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Resolve documented complements without creating opportunities or master relations. */ /** Resolve documented complements without creating opportunities or master relations. */
final class InvestmentComplements final class InvestmentComplements
@@ -23,6 +25,9 @@ final class InvestmentComplements
'finance' => ['Financiamento', 'financiamento|serviços financeiros'], 'finance' => ['Financiamento', 'financiamento|serviços financeiros'],
]; ];
/**
* @return array<array-key, mixed>
*/
public function options(string $origin, ?string $territory = null): array public function options(string $origin, ?string $territory = null): array
{ {
[$originKind, $originId] = array_pad(explode(':', $origin, 2), 2, ''); [$originKind, $originId] = array_pad(explode(':', $origin, 2), 2, '');
@@ -30,18 +35,24 @@ final class InvestmentComplements
$principal = null; $principal = null;
$relationEvidence = []; $relationEvidence = [];
if (str_starts_with($origin, 'portfolio:')) { 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'] ?? '')); $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]; $reference = ['key' => $origin, 'title' => $record->title, 'href' => '/carteira/'.$record->id, 'version' => $record->version_no];
$ids = app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record); $ids = app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { 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; $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]]; $serviceOrigins = [['dossier', $record->id]];
foreach (['project_id' => 'project', 'cluster_id' => 'cluster'] as $foreign => $type) { 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 { } else {
$reference = app(InvestorContext::class)->reference($origin); $reference = app(InvestorContext::class)->reference($origin);
@@ -49,8 +60,10 @@ final class InvestmentComplements
// The context resolver has already checked the actor's private scope. // The context resolver has already checked the actor's private scope.
// A private preparation is valid dossier input, not a public offer // A private preparation is valid dossier input, not a public offer
// from which this public-complement catalogue can infer services. // from which this public-complement catalogue can infer services.
if (($reference['visibility'] ?? 'public') === 'private') return []; if (($reference['visibility'] ?? 'public') === 'private') {
$principal = Opportunity::published()->findOrFail($reference['id']); return [];
}
$principal = Opportunity::published()->whereKey($reference['id'])->firstOrFail();
$text = $principal->summary; $text = $principal->summary;
$basis = 'Resumo cadastrado da oportunidade '.$principal->reference; $basis = 'Resumo cadastrado da oportunidade '.$principal->reference;
$projects = DB::table('opportunity_projects')->where('opportunity_id', $principal->id)->pluck('project_id'); $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(); $targetProjects = DB::table('opportunity_projects')->whereIn('project_id', $links->pluck('complementary_project_id'))->get();
$ids = [$principal->id, ...$targetProjects->pluck('opportunity_id')->all()]; $ids = [$principal->id, ...$targetProjects->pluck('opportunity_id')->all()];
$relationEvidence[$principal->id] = ['type' => 'same_fiche', 'source' => $basis]; $relationEvidence[$principal->id] = ['type' => 'same_fiche', 'source' => $basis];
foreach ($targetProjects as $targetProject) $relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project', foreach ($targetProjects as $targetProject) {
'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn($link) => (array)$link)->all()]; $relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project',
foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) $text .= "\n".$linked->summary; 'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn ($link) => (array) $link)->all()];
if (count($ids) > 1) $basis .= '; relações complementares públicas entre projectos'; }
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]]; $serviceOrigins = [['investment', (string) $principal->id]];
$classification = app(OpportunityClassification::class); $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(); $chains = $principalChains->pluck('target_id')->all();
if ($chains) { if ($chains) {
Opportunity::published()->whereNot('id', $principal->id)->chunkById(100, function ($records) use (&$ids, &$relationEvidence, $chains, $principalChains, $classification) { Opportunity::published()->whereNot('id', $principal->id)->chunkById(100, function ($records) use (&$ids, &$relationEvidence, $chains, $principalChains, $classification) {
foreach ($records as $candidate) { 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()); $shared = array_intersect($chains, $related->pluck('target_id')->all());
if ($shared) { if ($shared) {
$ids[] = $candidate->id; $ids[] = $candidate->id;
@@ -84,15 +103,25 @@ final class InvestmentComplements
$candidates = Opportunity::published()->whereIn('id', array_unique($ids))->get(); $candidates = Opportunity::published()->whereIn('id', array_unique($ids))->get();
$items = []; $items = [];
foreach (self::TERMS as $code => [$label, $pattern]) { 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 = []; $offers = [];
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
// A common corridor alone does not prove the component. Keep the supporting excerpt. // 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. // 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(?:'.$pattern.')\b/iu', $candidate->title)) {
if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) continue; 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(?: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, $offers[] = ['key' => 'opportunity:'.$candidate->id, 'title' => $candidate->title, 'href' => '/oportunidades/'.$candidate->slug,
'relationship' => $relationEvidence[$candidate->id] ?? null, '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')]; '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 are concrete published offerings explicitly associated with this source.
$services = []; $services = [];
if (in_array($code, ['certification', 'training', 'finance'], true) && Schema::hasTable('partnership_origins') && Schema::hasTable('partnership_interests')) { 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')
$services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id') ->where(function ($q) use ($serviceOrigins) {
->where(function ($q) use ($serviceOrigins) { foreach ($serviceOrigins as [$type, $id]) {
foreach ($serviceOrigins as [$type, $id]) $q->orWhere(fn($q) => $q->where('o.type', $type)->where('o.target_id', $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') })->where('p.status', 'published')->where('p.kind', 'offering')
->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']) ->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training')
->filter(function ($p) use ($territory) { ->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'])
if (!$territory) return true; ->filter(function ($p) use ($territory) {
$locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id'); if (! $territory) {
return $locations->isEmpty() || $locations->contains(fn($id) => $this->compatible($territory, $id)); return true;
})->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all(); }
} $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, $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]; 'source' => $basis, 'excerpt' => $excerpt, 'source_sha256' => hash('sha256', $text), 'opportunities' => $offers, 'services' => $services];
} }
return $items; return $items;
} }
private function compatible(string $a, string $b): bool private function compatible(string $a, string $b): bool
{ {
if ($a === $b) return true; if ($a === $b) {
return true;
}
$geo = app(InvestmentGeography::class); $geo = app(InvestmentGeography::class);
return in_array($b, $geo->descendants($a), true) || in_array($a, $geo->descendants($b), true); return in_array($b, $geo->descendants($a), true) || in_array($a, $geo->descendants($b), true);
} }
/**
* @param array<array-key, mixed> $input
* @param array<array-key, mixed> $previous
* @return array<array-key, mixed>
*/
public function resolve(array $input, array $previous = []): array public function resolve(array $input, array $previous = []): array
{ {
$resolved = []; $resolved = [];
foreach ($input as $choice) { foreach ($input as $choice) {
$target = $choice['target'] ?? null; $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 { try {
$item = collect($this->options($choice['origin'], $choice['territory_id'] ?? null))->firstWhere('code', $choice['code']); $item = collect($this->options($choice['origin'], $choice['territory_id'] ?? null))->firstWhere('code', $choice['code']);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { } catch (ModelNotFoundException $e) {
if (!$old) throw $e; if (! $old) {
throw $e;
}
$item = null; $item = null;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { } catch (HttpException $e) {
if (!$old || $e->getStatusCode() !== 404) throw $e; if (! $old || $e->getStatusCode() !== 404) {
throw $e;
}
$item = null; $item = null;
} }
$offer = $target && $item ? collect([...$item['opportunities'], ...$item['services']])->firstWhere('key', $target) : 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']; $resolved[$old['key']] = [...$old, 'availability' => 'review_required'];
continue; continue;
} }
abort_unless($item, 422, 'Complemento sem referência documentada neste investimento.'); 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 && ! $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 && ($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'] ?? '')); $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'], $resolved[$key] = ['key' => $key, 'origin' => $item['origin'], 'code' => $item['code'], 'title' => $item['title'],
'kind' => $target ? (str_starts_with($target, 'opportunity:') ? 'opportunity' : 'service') : 'need', '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']]; '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); return array_values($resolved);
} }
} }
+58 -18
View File
@@ -3,83 +3,123 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
/** An identified opportunity within the authorised dossier, never the account's last choice. */ /** An identified opportunity within the authorised dossier, never the account's last choice. */
final class InvestmentConsumerScope final class InvestmentConsumerScope
{ {
/**
* @param array<array-key, mixed> $context
*/
public function key(array $context, ?string $key, bool $required = false): ?string public function key(array $context, ?string $key, bool $required = false): ?string
{ {
$choices = $context['opportunity_choices'] ?? []; $choices = $context['opportunity_choices'] ?? [];
$key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null); $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($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.'); abort_if($required && count($choices) > 1 && ! $key, 422, 'Escolha a oportunidade em tratamento.');
return $key; return $key;
} }
/**
* @param array<array-key, mixed> $context
* @param array<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
public function facts(array $payload, array $context, ?string $key): array public function facts(array $payload, array $context, ?string $key): array
{ {
$key = $this->key($context, $key); $key = $this->key($context, $key);
$path = $payload['investor_path'] ?? []; $path = $payload['investor_path'] ?? [];
$defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown']; $defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown'];
// A dossier-wide dispensation cannot decide a distinct opportunity's needs. // 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 && count($context['opportunity_choices'] ?? []) > 1) {
if ($key && isset($path['opportunity_facts'][$key])) return [...$defaults, ...$path['opportunity_facts'][$key]]; return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])];
if (count($context['opportunity_choices'] ?? []) > 1) return $defaults; }
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'] ?? [])]; return [...$defaults, ...($path['facts'] ?? [])];
} }
/**
* @param array<array-key, mixed> $context
* @return array<array-key, mixed>
*/
public function context(array $context, ?string $key): array public function context(array $context, ?string $key): array
{ {
$choices = $context['opportunity_choices'] ?? []; $choices = $context['opportunity_choices'] ?? [];
if (!$choices) return $context; if (! $choices) {
return $context;
}
$key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null); $key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null);
$choice = $key ? collect($choices)->firstWhere('opportunity.key', $key) : null; $choice = $key ? collect(StructuredData::collectionItems($choices))->firstWhere('opportunity.key', $key) : null;
abort_if($key && !$choice, 422, 'Escolha uma oportunidade deste dossier.'); 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']; $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['fields'] = Arr::except($context['fields'], $specific);
$context['provenance'] = Arr::except($context['provenance'] ?? [], $specific); $context['provenance'] = Arr::except($context['provenance'] ?? [], $specific);
$context['selection'] = Arr::except($context['selection'], ['opportunity', 'territory', 'park']); $context['selection'] = Arr::except($context['selection'], ['opportunity', 'territory', 'park']);
if (!$choice) { if (! $choice) {
$context['opportunity_choices'] = []; $context['opportunity_choices'] = [];
$context['requirements_assessment']['requirements'] = []; $context['requirements_assessment']['requirements'] = [];
return $context; return $context;
} }
$context['selection']['opportunity'] = $choice['opportunity']; $context['selection']['opportunity'] = $choice['opportunity'];
$context['fields'] = [...$context['fields'], ...Arr::only($choice['opportunity']['fields'], $specific)]; $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']); 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']) { if ($choice['territory']) {
$context['selection']['territory'] = $choice['territory']; $context['selection']['territory'] = $choice['territory'];
$context['fields'] = [...Arr::except($context['fields'], array_keys(InvestmentGeography::FIELDS)), ...$choice['territory']['fields']]; $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 { } else {
$context['fields'] = Arr::except($context['fields'], ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location']); $context['fields'] = Arr::except($context['fields'], ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location']);
} }
$park = $choice['park'] ?? null; $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']['special_zone_id'] = $park['id'] ?? '';
$context['fields']['investment_regime'] = $park['fields']['investment_regime'] ?? 'general'; $context['fields']['investment_regime'] = $park['fields']['investment_regime'] ?? 'general';
$context['opportunity_choices'] = [$choice]; $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['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['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['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices' => [$choice], 'fields' => Arr::except($context['fields'], ['project_title', 'project_summary']), 'complements' => $context['complements']]);
return $context; return $context;
} }
/**
* @param array<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
public function bind(User $actor, string $id, array $payload, bool $required = false): array public function bind(User $actor, string $id, array $payload, bool $required = false): array
{ {
$case = app(VirtualDesk::class)->case($actor, $id, true); $case = app(VirtualDesk::class)->case($actor, $id, true);
$context = app(InvestorContext::class)->state($case); $context = app(InvestorContext::class)->state($case);
$scoped = $this->context($context, $payload['context_opportunity'] ?? null); $scoped = $this->context($context, $payload['context_opportunity'] ?? null);
$choice = $scoped['consumer_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.'); 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']); if (! $choice) {
return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
}
// Canonical server snapshot; client text cannot establish a different origin or territory. // Canonical server snapshot; client text cannot establish a different origin or territory.
$payload['context_opportunity'] = $choice['opportunity']['key']; $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); $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; return $payload;
} }
} }
+15 -6
View File
@@ -7,6 +7,10 @@ final class InvestmentContextRequirements
{ {
public const VERSION = 'context-requirements-20261023'; public const VERSION = 'context-requirements-20261023';
/**
* @param array<array-key, mixed> $context
* @return array<array-key, mixed>
*/
public function assess(array $context): array public function assess(array $context): array
{ {
$rows = []; $rows = [];
@@ -32,16 +36,21 @@ final class InvestmentContextRequirements
['Actividades efectivas e modalidade', 'Requerimento e anexos da modalidade aplicável; acto quando emitido'], ['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/'); '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]) { 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', if (in_array($activity, $activities, true)) {
['Caracterização do estabelecimento e actividade', 'Documentos e regime aplicáveis, incluindo simplificado ou mera comunicação quando preenchidas as suas condições'], $add($activity, $title, 'conditional', 'S08', 'BAU, IP / entidade licenciadora da modalidade',
$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/'); ['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) { 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', if (in_array($activity, $activities, true)) {
['Modalidade e dimensão da actividade', 'Fonte e documentação sectorial específica a associar ao dossier'], $add($activity, $title, 'undetermined', 'S08', 'Autoridade sectorial competente a identificar para a modalidade declarada',
'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'); ['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, 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.']; '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.'];
} }
+46 -15
View File
@@ -10,40 +10,65 @@ final class InvestmentCorridorContext
public function key(Opportunity $record): ?string public function key(Opportunity $record): ?string
{ {
$name = data_get($record->workflow_context, 'corridor_agribusiness.corridor'); $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; 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<array-key, mixed>
*/
public function catalogueProvinces(Opportunity $record): array public function catalogueProvinces(Opportunity $record): array
{ {
if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) return []; if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) {
if ($record->primary_territory_id || $record->additionalTerritories()->exists()) return []; return [];
}
if ($record->primary_territory_id || $record->additionalTerritories()->exists()) {
return [];
}
$key = $this->key($record); $key = $this->key($record);
return $key ? $this->provinces($key) : []; return $key ? $this->provinces($key) : [];
} }
/**
* @return array<array-key, mixed>
*/
public function provinces(string $key): array public function provinces(string $key): array
{ {
$entry = config('investment_corridors.'.$key); $entry = config('investment_corridors.'.$key);
abort_unless(is_array($entry), 422, 'Seleccione um corredor disponível.'); abort_unless(is_array($entry), 422, 'Seleccione um corredor disponível.');
$records = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province') $records = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')
->whereIn('source_code', $entry['provinces'])->get(['id', 'name', 'parent_id', 'location_type', 'source_code'])->all(); ->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; return $records;
} }
public function compatible(string $key, ?string $territory): bool public function compatible(string $key, ?string $territory): bool
{ {
if (!$territory) return true; if (! $territory) {
return true;
}
$path = app(InvestmentGeography::class)->path($territory); $path = app(InvestmentGeography::class)->path($territory);
return $path !== [] && in_array($path[0]->id, array_column($this->provinces($key), 'id'), true); 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<array-key, mixed>
*/
public function parks(?string $key, ?string $territory): 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.'); 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'); $selectedPath = array_column(app(InvestmentGeography::class)->path($territory), 'id');
$candidates = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(); $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) { foreach ($candidates as $zone) {
$ids = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->where('validation_status', 'validated') $ids = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->where('validation_status', 'validated')
->whereIn('scope_type', ['legal_scope', 'administrative', 'site']) ->whereIn('scope_type', ['legal_scope', 'administrative', 'site'])
->where(fn($q)=>$q->whereNull('valid_from')->orWhereDate('valid_from','<=',today())) ->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(); ->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']; if ($zone->primary_territory_id) {
$ids[] = (object) ['geo_location_id' => $zone->primary_territory_id, 'scope_type' => 'site'];
}
foreach ($ids as $link) { foreach ($ids as $link) {
$id=$link->geo_location_id; $id = $link->geo_location_id;
if (!$this->compatible($key, $id)) continue; if (! $this->compatible($key, $id)) {
continue;
}
$path = array_column(app(InvestmentGeography::class)->path($id), 'id'); $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))) { 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; $result[] = $zone->only(['id', 'name', 'category', 'version_no']);
break;
} }
} }
} }
return $result; return $result;
} }
} }
+4 -1
View File
@@ -12,7 +12,10 @@ final class InvestmentGeography
{ {
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality']; 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<array-key, mixed> $filters
* @return array<array-key, mixed>
*/
public function provinceSearch(array $filters): array public function provinceSearch(array $filters): array
{ {
if (empty($filters['province_id']) && is_string($filters['province'] ?? null) && trim($filters['province']) !== '') { if (empty($filters['province_id']) && is_string($filters['province'] ?? null) && trim($filters['province']) !== '') {
+49 -18
View File
@@ -4,47 +4,73 @@ namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController; use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity; use App\Models\Opportunity;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** Multiple identified references in the existing encrypted investment context. */ /** Multiple identified references in the existing encrypted investment context. */
final class InvestmentOpportunityChoices final class InvestmentOpportunityChoices
{ {
/**
* @param array<array-key, mixed> $ids
* @return array<array-key, mixed>
*/
public function portfolioOrigins(array $ids, int $opportunity): array public function portfolioOrigins(array $ids, int $opportunity): array
{ {
$origins = []; $origins = [];
foreach (array_unique($ids) as $id) { 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; $kind = null;
if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) $kind = 'corridor'; if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) {
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { $kind = 'corridor';
if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) $kind = rtrim($relation, 's');
} }
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, $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))]; 'version' => $record->version_no, 'sha256' => hash('sha256', json_encode([$record->title, $record->source, $record->content], JSON_THROW_ON_ERROR))];
} }
return $origins; 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<array-key, mixed> $entries
* @param array<array-key, mixed> $saved
* @return array<array-key, mixed>
*/
public function mergeEntries(array $saved, array $entries): array public function mergeEntries(array $saved, array $entries): array
{ {
foreach ($entries as $entry) { foreach ($entries as $entry) {
$index = array_search($entry['opportunity']['key'], array_column(array_column($saved, 'opportunity'), 'key'), true); $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(); $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; return $saved;
} }
/**
* @param array<array-key, mixed> $choices
* @param array<array-key, mixed> $previous
* @return array<array-key, mixed>
*/
public function resolve(array $choices, array $previous = [], bool $pending = false): array public function resolve(array $choices, array $previous = [], bool $pending = false): array
{ {
$result = []; $result = [];
foreach ($choices as $choice) { foreach ($choices as $choice) {
$ref = app(InvestorContext::class)->reference($choice['source']); $ref = app(InvestorContext::class)->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422, 'Seleccione uma ficha de oportunidade.'); 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']); $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); $origins = $this->portfolioOrigins($choice['portfolio_origins'] ?? array_column($old['portfolio_origins'] ?? [], 'id'), (int) $record->id);
$corridors = app(InvestmentCorridorContext::class); $corridors = app(InvestmentCorridorContext::class);
@@ -52,14 +78,18 @@ final class InvestmentOpportunityChoices
$provinces = $corridor ? $corridors->provinces($corridor) : []; $provinces = $corridor ? $corridors->provinces($corridor) : [];
$documented = $record->primary_territory_id; $documented = $record->primary_territory_id;
$territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null); $territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null);
if (!$territory && count($provinces) === 1) $territory = 'territory:'.$provinces[0]->id; if (! $territory && count($provinces) === 1) {
if ($corridor && $territory) abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.'); $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; $previousTerritory = $old['territory']['key'] ?? null;
$newAlternative = $documented && $territory !== 'territory:'.$documented $newAlternative = $documented && $territory !== 'territory:'.$documented
&& (! $old || ($old['documented_territory_id'] ?? null) !== $documented || $territory !== $previousTerritory); && (! $old || ($old['documented_territory_id'] ?? null) !== $documented || $territory !== $previousTerritory);
$corridorChanged = $old && ($old['corridor_key'] ?? $corridors->key($record)) !== $corridor; $corridorChanged = $old && ($old['corridor_key'] ?? $corridors->key($record)) !== $corridor;
$incompatibleOld = $corridorChanged && $previousTerritory && !$corridors->compatible($corridor, substr($previousTerritory, 10)); $incompatibleOld = $corridorChanged && $previousTerritory && ! $corridors->compatible($corridor, substr($previousTerritory, 10));
if (!$pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && !$incompatibleOld))) { if (! $pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && ! $incompatibleOld))) {
abort_unless(! empty($choice['confirm_location_change']) && mb_strlen(trim($choice['location_reason'] ?? '')) >= 20, 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.'); 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; $park = $parkKey ? app(InvestorContext::class)->reference($parkKey) : null;
if ($park) { if ($park) {
$eligible = $corridor ? array_column($corridors->parks($corridor, $location['id'] ?? null), 'id') : []; $eligible = $corridor ? array_column($corridors->parks($corridor, $location['id'] ?? null), 'id') : [];
if (!in_array($park['id'], $eligible, true)) { 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.'); abort_if(! empty($choice['park']), 422, 'A zona ou parque não é compatível com a localização desta oportunidade.');
$park = null; $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] : []), $result[] = ['opportunity' => $ref, 'territory' => $location, ...($origins ? ['portfolio_origins' => $origins] : []),
'park' => $park, 'park' => $park,
'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null, 'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null,
'allowed_provinces' => array_map(fn($p)=>(array)$p, $provinces), '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)) : [], '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'] ?? []), 'activities' => $choice['activities'] ?? ($old['activities'] ?? []),
'documented_territory_id' => $documented, 'location_reason' => $choice['location_reason'] ?? '', 'documented_territory_id' => $documented, 'location_reason' => $choice['location_reason'] ?? '',
'location_state' => $location ? ($documented && $territory === 'territory:'.$documented ? 'documented' : 'investor_selected') : 'undetermined', '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' => data_get($record->workflow_context, 'corridor_agribusiness.source'),
'source_sha256' => data_get($record->workflow_context, 'corridor_agribusiness.source_sha256')]; 'source_sha256' => data_get($record->workflow_context, 'corridor_agribusiness.source_sha256')];
} }
return $result; return $result;
} }
} }
+158 -56
View File
@@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Support\StructuredData;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -11,6 +12,7 @@ use Illuminate\Support\Str;
class InvestmentZoneIntegralDetails class InvestmentZoneIntegralDetails
{ {
public const GROUPS = ['titles', 'phases', 'plots', 'occupants', 'observations', 'chains']; public const GROUPS = ['titles', 'phases', 'plots', 'occupants', 'observations', 'chains'];
public const EXTRA = [ public const EXTRA = [
'titles' => ['kind', 'identifier', 'valid_from', 'valid_until'], 'titles' => ['kind', 'identifier', 'valid_from', 'valid_until'],
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'], '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'], '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'], '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']; public const COMMON = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', 'disclosure', 'private_note', 'support_document_ids'];
/**
* @param array<array-key, mixed> $before
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function merge(InvestmentZone $zone, array $before, array $input): 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)]; $rules = ['schema_version' => 'sometimes|in:1', 'remove_ids' => 'sometimes|array:'.implode(',', self::GROUPS)];
foreach (self::GROUPS as $group) { foreach (self::GROUPS as $group) {
$rules[$group] = 'sometimes|array|max:200'; $rules[$group] = 'sometimes|array|max:200';
$rules[$group.'.*'] = 'array:'.implode(',', [...self::COMMON, ...self::EXTRA[$group]]); $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 (['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) {
foreach (self::EXTRA[$group] as $key) $rules[$group.'.*.'.$key]=match ($key) { $rules[$group.'.*.'.$key] = $rule;
'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($remove as$id){abort_unless(isset($rows[$id]),422,'O detalhe a remover não pertence ao parque.');unset($rows[$id]);} foreach (self::EXTRA[$group] as $key) {
abort_if(count($rows)>200,422,'Limite de detalhes excedido.');$result[$group]=array_values($rows); $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. // References resolve only inside this park, including new items in the same transaction.
foreach(self::GROUPS as$group)foreach($result[$group]as$item){ foreach (self::GROUPS as $group) {
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.'); foreach ($result[$group] as $item) {
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 (['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;}} foreach (['phases', 'plots'] as $group) {
$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;} $codes = [];
$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;} foreach ($result[$group] as $item) {
return [...$result,'schema_version'=>1]; 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<array-key, mixed> $fields
* @return array<array-key, mixed>
*/
public function publicData(array $fields): array public function publicData(array $fields): array
{ {
$public=['schema_version'=>1]; $public = ['schema_version' => 1];
foreach(self::GROUPS as$group){$public[$group]=[];foreach($fields[$group]??[]as$item){ foreach (self::GROUPS as $group) {
if(!($item['disclosure']??false))continue; $public[$group] = [];
$keys=['id','label','label_en','description','description_en','knowledge','source_reference','source_date','scope',...self::EXTRA[$group]]; foreach ($fields[$group] ?? [] as $item) {
$visible=array_intersect_key($item,array_flip($keys));unset($visible['organization_id'],$visible['documentary_name'],$visible['geometry_id'],$visible['zone_link_id']); if (! ($item['disclosure'] ?? false)) {
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;} continue;
if($group==='occupants')abort_unless(!empty($visible['organization_name']),422,'Ocupante por identificar permanece privado.'); }
$public[$group][]=$visible; $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. // 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; return $public;
} }
} }
+142 -72
View File
@@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Support\StructuredData;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -14,119 +15,188 @@ use Illuminate\Validation\ValidationException;
class InvestmentZoneOperationalDetails class InvestmentZoneOperationalDetails
{ {
public const MIGRATION = '2026_10_16_140000_add_investment_zone_operational_details'; public const MIGRATION = '2026_10_16_140000_add_investment_zone_operational_details';
public const GROUPS = ['responsibilities', 'conditions', 'risks']; 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<array-key, mixed>|null
*/
public function stored(InvestmentZone $zone): ?array public function stored(InvestmentZone $zone): ?array
{ {
$raw = $zone->getRawOriginal('operational_details'); $raw = $zone->getRawOriginal('operational_details');
return $raw ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null; return $raw ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
} }
/**
* @return array<array-key, mixed>
*/
public function recover(InvestmentZone $zone): array public function recover(InvestmentZone $zone): array
{ {
$stored = $this->stored($zone); $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 { 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); return json_decode(Crypt::decryptString($stored['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) { } 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<array-key, mixed>
*/
public function publicData(InvestmentZone $zone): array public function publicData(InvestmentZone $zone): array
{ {
return $this->stored($zone)['public'] ?? []; 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<array-key, mixed> $before
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function merge(InvestmentZone $zone, array $before, array $input, ?string $operatorId): 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($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.'); 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', $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', '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']; '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'; $common = 'id,label,knowledge,disclosure,source_reference,source_date,scope,private_note,support_document_ids';
foreach(self::GROUPS as $g){ 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'); $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]; $rules[$g.'.*'] = ['array:'.$common.$extra];
foreach(['id'=>'nullable|uuid','label'=>'required|string|max:300','knowledge'=>'required|in:unknown,documented,disputed','disclosure'=>'required|boolean', 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', '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;} 'support_document_ids' => 'sometimes|array|max:10', 'support_document_ids.*' => 'uuid'] as $k => $r) {
$rules['remove_ids.'.$g]='sometimes|array|max:60';$rules['remove_ids.'.$g.'.*']='uuid'; $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 (['source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500'] as $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;} $rules['review.'.$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;} 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) {
abort_if(array_diff(array_keys($input),['schema_version',...self::GROUPS,'review','remove_ids','integral']),422,'Campo L1 não reconhecido.'); $rules['responsibilities.*.'.$k] = $r;
$data=Validator::make($input,$rules)->validate();$result=$before; }
foreach(self::GROUPS as $g){ 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) {
$rows=collect($before[$g] ?? [])->keyBy('id')->all();$removals=$data['remove_ids'][$g] ?? []; $seen=[]; $rules['conditions.*.'.$k] = $r;
foreach($data[$g] ?? [] as $item){ }
$id=$item['id'] ?? (string)Str::uuid(); 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) {
abort_if(in_array($id,$seen,true)||in_array($id,$removals,true),422,'Identificador L1 repetido ou removido na mesma operação.');$seen[]=$id; $rules['risks.*.'.$k] = $r;
$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.'); } abort_if(array_diff(array_keys($input), ['schema_version', ...self::GROUPS, 'review', 'remove_ids', 'integral']) !== [], 422, 'Campo L1 não reconhecido.');
else { abort_unless(trim($item['source_reference'] ?? '')!=='',422,'Identifique a fonte da informação documentada ou divergente.'); } $data = Validator::make($input, $rules)->validate();
if($g==='conditions' && isset($item['value'])){ $result = $before;
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 (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'])){ if ($g === 'conditions' && isset($item['value'])) {
abort_if($item['valid_until']<$item['valid_from'],422,'O fim da vigência é anterior ao início; preserve datas desconhecidas como desconhecidas.'); 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'){ if ($g === 'conditions' && isset($item['valid_from'],$item['valid_until'])) {
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.'); 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'){ if ($g === 'risks' && ! empty($item['channel']) && ($item['channel_owner'] ?? 'unknown') === 'park') {
$org=$item['organization_id'] ?? null; 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($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){ if ($g === 'responsibilities') {
$operator=$operatorId; $org = $item['organization_id'] ?? null;
abort_unless($operator===$org,422,'O operador é a associação canónica da ficha; não crie outro no suplemento.'); 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){ 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) 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.'); ->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]);} foreach ($removals as $id) {
abort_if(count($rows)>($g==='conditions'?60:40),422,'Limite de detalhes por grupo excedido.'); abort_unless(isset($rows[$id]), 422, 'Detalhe a remover não pertence à ficha.');
$result[$g]=array_values($rows); 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;} $pairs = [];
if(array_key_exists('review',$data)){$result['review']=[...($before['review'] ?? []),...$data['review']];} foreach ($result['responsibilities'] as $item) {
if(array_key_exists('integral',$data)){$result['integral']=app(InvestmentZoneIntegralDetails::class)->merge($zone,$before['integral'] ?? [],$data['integral']);} $key = $item['role'].'|'.($item['organization_id'] ?? $item['documentary_name'] ?? $item['id']);
$result['schema_version']=1; 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; return $result;
} }
/**
* @param array<array-key, mixed> $fields
* @return array<array-key, mixed>
*/
public function pack(array $fields): array public function pack(array $fields): array
{ {
$public=[]; $public = [];
foreach(self::GROUPS as$g){ foreach (self::GROUPS as $g) {
$public[$g]=[]; $public[$g] = [];
foreach($fields[$g] ?? []as$item){ foreach ($fields[$g] ?? [] as $item) {
if(!($item['disclosure'] ?? false)){continue;} if (! ($item['disclosure'] ?? false)) {
$keys=['label','knowledge','source_reference','source_date','scope']; continue;
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; $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']);} if (isset($fields['integral'])) {
$public['review']=array_intersect_key($fields['review'] ?? [],array_flip(['source_reference','source_date','scope','confidence','last_reviewed_at','next_review_at'])); $public['integral'] = app(InvestmentZoneIntegralDetails::class)->publicData($fields['integral']);
return ['schema_version'=>1,'public'=>$public,'private_ciphertext'=>Crypt::encryptString(json_encode($fields,JSON_THROW_ON_ERROR))]; }
$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))];
} }
} }
@@ -3,8 +3,10 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException; 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. */ /** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class InvestmentZoneOperationalDetailsInstallation class InvestmentZoneOperationalDetailsInstallation
{ {
public const SOURCE='investment-zone-details-installation'; public const SOURCE = 'investment-zone-details-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): 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']; 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 public function fingerprint(): string
{ {
$files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files()); $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())];} foreach (File::allFiles(public_path('build')) as $file) {
usort($files,fn($a,$b)=>strcmp($a[0],$b[0])); $files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR)); }
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.'); abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
} }
private function evidence(array $target,string $code): array
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code): array
{ {
$qa=app()->environment('testing') && (int)$target['port']===55441 && str_ends_with($target['database'],'_test') $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'); && config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];} if ($qa) {
if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];} return ['ready' => true, 'mode' => 'isolated_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'] ?? []; if (app()->environment('testing')) {
$ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url') return ['ready' => false, 'mode' => 'invalid_QA'];
&& 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'] $path = storage_path('app/private/investmoz-v2/park-details/readiness.json');
&& ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400 $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['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') && ($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; && ($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));} foreach (['dump', 'files'] as $kind) {
return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null]; $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<array-key, mixed>
*/
public function preview(User $actor): 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(); $this->authorize($actor);
$ledgerCount=DB::table('migrations')->where('migration',InvestmentZoneOperationalDetails::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('investment_zones','operational_details'); $target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$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'"); $code = $this->fingerprint();
$correct=$shape && $shape->data_type==='jsonb' && $shape->is_nullable==='YES'; $ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalDetails::MIGRATION)->count();
$consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[]; $ledger = $ledgerCount === 1;
if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';} $column = Schema::hasColumn('investment_zones', 'operational_details');
try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();} $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'");
$evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';} $correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES';
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.';} $consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
$state=['target'=>$target,'migration'=>InvestmentZoneOperationalDetails::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column, $reasons = [];
'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence]; if (! $consistent) {
return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))]; $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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{ {
$this->authorize($actor,true); $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.'); 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(); $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)); return DB::transaction(function () use ($actor, $data) {
$old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first(); DB::select('SELECT pg_advisory_xact_lock(161026,140000)');
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];} $requestHash = hash('sha256', json_encode($data, 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'])); $old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if(!$plan['installed']){ if ($old) {
config(['investment-zone-details.authenticated_installation'=>true]); $receipt = json_decode($old->after_data, true);
try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.InvestmentZoneOperationalDetails::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');} 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.');
finally{config(['investment-zone-details.authenticated_installation'=>false]);}
return [...$receipt['result'], 'replayed' => true];
} }
abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.'); $plan = $this->preview($actor);
$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']]; abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'investment-zone-profile','entity_id'=>null,'actor_user_id'=>$actor->id, abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]); 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; return $result;
}); });
} }
+128 -73
View File
@@ -13,131 +13,186 @@ use Illuminate\Validation\ValidationException;
class InvestmentZoneOperationalProfile class InvestmentZoneOperationalProfile
{ {
public const MIGRATION = '2026_10_16_130000_add_investment_zone_editorial_draft'; 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<array-key, mixed>|null
*/
public function draft(InvestmentZone $zone): ?array public function draft(InvestmentZone $zone): ?array
{ {
$raw = $zone->getRawOriginal('editorial_draft'); $raw = $zone->getRawOriginal('editorial_draft');
if (! $raw) { return null; } if (! $raw) {
try { $data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR); } return null;
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.']); } }
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.'); 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; 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 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<array-key, mixed>
*/
public function management(InvestmentZone $zone): array public function management(InvestmentZone $zone): array
{ {
$draft = $this->draft($zone); $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, return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no,
'has_private_draft'=>$draft !== null, 'profile_available'=>$this->installed(), 'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no,
'operational_details'=>($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)), 'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(),
'details_available'=>app(InvestmentZoneOperationalDetails::class)->installed()]; 'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
'details_available' => app(InvestmentZoneOperationalDetails::class)->installed()];
} }
/**
* @param array<array-key, mixed> $record
* @return array<array-key, mixed>
*/
public function publicDetails(array $record): array public function publicDetails(array $record): array
{ {
unset($record['editorial_draft'], $record['operational_details']); unset($record['editorial_draft'], $record['operational_details']);
foreach (['activities','resources'] as $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]); } if (isset($record[$key])) {
$record[$key] = array_map(fn ($item) => array_intersect_key($item, array_flip(self::DETAIL_PUBLIC)), $record[$key]);
}
} }
return $record; return $record;
} }
private function mergeDetails(string $zoneId,array $before, array $after): array /**
* @param array<array-key, mixed> $after
* @param array<array-key, mixed> $before
* @return array<array-key, mixed>
*/
private function mergeDetails(string $zoneId, array $before, array $after): array
{ {
$result=[]; $result = [];
foreach ($after as $item) { foreach ($after as $item) {
$old = collect($before)->first(fn($b)=>isset($item['id']) ? ($b['id'] ?? null)===$item['id'] : ($b['name'] ?? null)===($item['name'] ?? null)); $old = collect($before)->first(fn ($b) => isset($item['id']) ? ($b['id'] ?? null) === $item['id'] : ($b['name'] ?? null) === ($item['name'] ?? null));
$item=[...($old ?? []), ...$item]; $item = [...($old ?? []), ...$item];
$item['id'] ??= (string) Str::uuid(); $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 (isset($item['capacity_value']) && empty($item['capacity_unit'])) {
if (!empty($item['economic_activity_id'])) { throw ValidationException::withMessages(['resources' => 'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']);
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 (! 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) { 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.'); 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(); $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($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; return $result;
} }
/** Called with zone row locked by InvestmentZones. Only this encrypted column is written. */ /** Called with zone row locked by InvestmentZones. Only this encrypted column is written.
public function prepare(InvestmentZone $zone,array $data,int $actor): InvestmentZone * @param array<array-key, mixed> $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($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.'); 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); $previous = $this->draft($zone);
$base=$previous['fields'] ?? array_intersect_key($zone->toArray(),array_flip(self::FIELDS)); $base = $previous['fields'] ?? array_intersect_key($zone->toArray(), array_flip(self::FIELDS));
$fields=[...$base,...array_intersect_key($data,array_flip(self::FIELDS))]; $fields = [...$base, ...array_intersect_key($data, array_flip(self::FIELDS))];
$details=app(InvestmentZoneOperationalDetails::class); $details = app(InvestmentZoneOperationalDetails::class);
if (array_key_exists('operational_details',$data)) { 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); $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)) { } elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) {
$fields['operational_details']=$details->recover($zone); $fields['operational_details'] = $details->recover($zone);
} }
$details=app(InvestmentZoneOperationalDetails::class); $details = app(InvestmentZoneOperationalDetails::class);
if (array_key_exists('operational_details',$data)) { 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); $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)) { } elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) {
$fields['operational_details']=$details->recover($zone); $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]); } } foreach (['activities', 'resources'] as $key) {
$draft=['schema_version'=>1,'base_public_version'=>$previous['base_public_version'] ?? (int)$zone->version_no, if (array_key_exists($key, $data)) {
'working_version'=>$this->nextVersion($zone),'fields'=>$fields,'actor_id'=>$actor,'prepared_at'=>now()->toIso8601String()]; $fields[$key] = $this->mergeDetails($zone->id, $base[$key] ?? [], $data[$key]);
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]); }
$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(); return $zone->fresh();
} }
/** Apply only inside the publication transaction; a failed validation rolls everything back. */ /** 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); $draft = $this->draft($zone);
abort_unless($version===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de publicar.'); abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.');
if (!$draft) { return; } if (! $draft) {
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.'); return;
$zone->fill(array_intersect_key($draft['fields'],array_flip(self::FIELDS))); }
if (array_key_exists('operational_details',$draft['fields'])) { 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->setAttribute('operational_details',app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details'])); $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. // 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]); } foreach (['activities', 'resources'] as $key) {
$zone->setAttribute('editorial_draft',null); $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); $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_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.'); 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(); $draft['base_public_version'] = $public;
DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]); $draft['working_version'] = $this->nextVersion($zone);
$this->snapshot($zone,$draft['working_version'],$actor,'profile_reconciled',['public'=>[...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],'draft'=>$draft]); $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(); return $zone->fresh();
} }
public function snapshot(InvestmentZone $zone,int $version,int $actor,string $action,array $data): void /**
* @param array<array-key, mixed> $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); $json = json_encode($data, JSON_THROW_ON_ERROR);
DB::table('investment_zone_revisions')->insert(['id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'version_no'=>$version, $hash = hash('sha256', $json);
'actor_id'=>$actor,'action'=>$action,'snapshot'=>Crypt::encryptString($json),'sha256'=>$hash]); DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version,
DB::table('audit_events')->insert(['source_system'=>'investment-zones','entity_type'=>'investment_zones','entity_id'=>$zone->id, 'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]);
'actor_user_id'=>$actor,'action'=>$action,'after_data'=>json_encode(['version_no'=>$version,'sha256'=>$hash],JSON_THROW_ON_ERROR)]); 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)]);
} }
} }
+38 -24
View File
@@ -8,41 +8,55 @@ use Illuminate\Support\Facades\Validator;
class InvestmentZonePathContext 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<array-key, mixed>
*/
public function context(string $id, int $version): array
{ {
$zone=app(InvestmentZones::class)->visible()->where('category','industrial_park')->findOrFail($id); $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.'); 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]; 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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function input(array $input): 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<array-key, mixed> $input
*/
public function attach(User $actor, string $id, array $input): void
{ {
abort_unless(app(InvestorPath::class)->available(),404); 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(); $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){ DB::transaction(function () use ($actor, $id, $data) {
$desk=app(VirtualDesk::class);$case=$desk->case($actor,$id,true,true); $desk = app(VirtualDesk::class);
abort_if($case->closed_at,409,'O dossier está encerrado.'); $case = $desk->case($actor, $id, true, true);
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.'); abort_if($case->closed_at, 409, 'O dossier está encerrado.');
$payload=$desk->decode($case->payload); 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.');
foreach($payload['investor_path']['zone_contexts'] ?? [] as $existing){ $payload = $desk->decode($case->payload);
if($existing['zone_id']===$data['zone_id'] && (int)$existing['consulted_public_version']===(int)$data['consulted_public_version']){return;} 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. // Lock the public record while checking the consulted version and recording its reference.
DB::table('investment_zones')->where('id',$data['zone_id'])->lockForUpdate()->firstOrFail(); DB::table('investment_zones')->where('id', $data['zone_id'])->lockForUpdate()->firstOrFail();
$context=$this->context($data['zone_id'],(int)$data['consulted_public_version']); $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.'); 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()]; $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()]); 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']); $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']);
}); });
} }
} }
@@ -3,8 +3,10 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException; 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. */ /** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class InvestmentZoneProfileInstallation class InvestmentZoneProfileInstallation
{ {
public const SOURCE='investment-zone-profile-installation'; public const SOURCE = 'investment-zone-profile-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): array public function files(): array
{ {
return ['app/Models/InvestmentZone.php','app/Services/InvestmentZones.php','app/Services/InvestmentZoneOperationalProfile.php', 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/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', '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/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', 'resources/js/pages/investor-path/show.tsx', 'resources/js/components/investment-zone-installation.tsx',
'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php']; 'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php'];
} }
public function fingerprint(): string public function fingerprint(): string
{ {
$files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files()); $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())];} foreach (File::allFiles(public_path('build')) as $file) {
usort($files,fn($a,$b)=>strcmp($a[0],$b[0])); $files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR)); }
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.'); abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);} if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
} }
private function evidence(array $target,string $code): array
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code): array
{ {
$qa=app()->environment('testing') && (int)$target['port']===55441 && str_ends_with($target['database'],'_test') $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'); && config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];} if ($qa) {
if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];} return ['ready' => true, 'mode' => 'isolated_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'] ?? []; if (app()->environment('testing')) {
$ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url') return ['ready' => false, 'mode' => 'invalid_QA'];
&& 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'] $path = storage_path('app/private/investmoz-v2/park-profile/readiness.json');
&& ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400 $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['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') && ($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; && ($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));} foreach (['dump', 'files'] as $kind) {
return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null]; $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<array-key, mixed>
*/
public function preview(User $actor): 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(); $this->authorize($actor);
$ledgerCount=DB::table('migrations')->where('migration',InvestmentZoneOperationalProfile::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('investment_zones','editorial_draft'); $target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$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'"); $code = $this->fingerprint();
$correct=$shape && $shape->data_type==='text' && $shape->is_nullable==='YES'; $ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalProfile::MIGRATION)->count();
$consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[]; $ledger = $ledgerCount === 1;
if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';} $column = Schema::hasColumn('investment_zones', 'editorial_draft');
try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();} $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'");
$evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';} $correct = $shape && $shape->data_type === 'text' && $shape->is_nullable === 'YES';
$state=['target'=>$target,'migration'=>InvestmentZoneOperationalProfile::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column, $consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence]; $reasons = [];
return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))]; 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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{ {
$this->authorize($actor,true); $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.'); 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(); $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)); return DB::transaction(function () use ($actor, $data) {
$old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first(); DB::select('SELECT pg_advisory_xact_lock(161026,130000)');
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];} $requestHash = hash('sha256', json_encode($data, 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'])); $old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if(!$plan['installed']){ if ($old) {
config(['investment-zone-profile.authenticated_installation'=>true]); $receipt = json_decode($old->after_data, true);
try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');} 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.');
finally{config(['investment-zone-profile.authenticated_installation'=>false]);}
return [...$receipt['result'], 'replayed' => true];
} }
abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.'); $plan = $this->preview($actor);
$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']]; abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'investment-zone-profile','entity_id'=>null,'actor_user_id'=>$actor->id, abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]); 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; return $result;
}); });
} }
+20 -14
View File
@@ -97,15 +97,16 @@ class InvestmentZones
return DB::transaction(function () use ($id, $data, $actor) { return DB::transaction(function () use ($id, $data, $actor) {
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone; $zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
if ($id && $zone->category === 'industrial_park') { if ($id && $zone->category === 'industrial_park') {
$prepared=app(InvestmentZoneOperationalProfile::class)->prepare($zone,$data,$actor); $prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
$this->storePendingLocations($zone,$data['locations'] ?? [],$actor); $this->storePendingLocations($zone, $data['locations'] ?? [], $actor);
return $prepared; return $prepared;
} }
abort_if($id && (int) $zone->version_no !== (int) $data['version_no'], 409, 'Versão alterada. Recarregue antes de guardar.'); 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; $version = $id ? (int) $zone->version_no + 1 : 1;
$locations = $data['locations'] ?? []; $locations = $data['locations'] ?? [];
unset($data['locations'], $data['version_no']); 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, $zone->fill([...$data, 'editor_id' => $actor, 'reviewer_id' => null, 'published_at' => null,
'version_no' => $version, 'publication_status' => 'draft', 'validation_status' => 'pending'])->save(); 'version_no' => $version, 'publication_status' => 'draft', 'validation_status' => 'pending'])->save();
foreach ($locations as $location) { foreach ($locations as $location) {
@@ -127,8 +128,11 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) { return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class); $profile = app(InvestmentZoneOperationalProfile::class);
if ($zone->category === 'industrial_park') { $profile->promote($zone,$version); } if ($zone->category === 'industrial_park') {
else { abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); } $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(); $source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->lockForUpdate()->first();
$evidence = $zone->source_evidence ?? []; $evidence = $zone->source_evidence ?? [];
$officialEvidence = collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional'], true) $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) { return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile=app(InvestmentZoneOperationalProfile::class); $profile = app(InvestmentZoneOperationalProfile::class);
abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int)$zone->version_no) !== $version, 409, 'Versão alterada.'); 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(); $zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save();
$this->snapshot($zone, $actor, 'suspended'); $this->snapshot($zone, $actor, 'suspended');
@@ -201,7 +205,7 @@ class InvestmentZones
private function snapshot(InvestmentZone $zone, int $actor, string $action): void 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, DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id,
'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action, 'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action,
'snapshot' => Crypt::encryptString($json), 'sha256' => hash('sha256', $json)]); '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)]); '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. */ /** Existing explicit territorial control: new associations remain pending, never promoted with the profile.
private function storePendingLocations(InvestmentZone $zone,array $locations,int $actor): void * @param array<array-key, mixed> $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([ DB::table('investment_zone_locations')->insertOrIgnore([
'id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'geo_location_id'=>$location['geo_location_id'], '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'], '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(), 'legal_source_id' => $location['legal_source_id'] ?? null, 'validation_status' => 'pending', 'editor_id' => $actor, 'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
]); ]);
} }
} }
+175 -70
View File
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
@@ -27,63 +28,83 @@ final class InvestorContext
public function __construct(private readonly VirtualDesk $desk) {} public function __construct(private readonly VirtualDesk $desk) {}
/**
* @return array<array-key, mixed>|null
*/
public function draft(User $actor, string $id): ?array public function draft(User $actor, string $id): ?array
{ {
$case = $this->desk->case($actor, $id, true); $case = $this->desk->case($actor, $id, true);
$draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string)$actor->id] ?? 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;
return $draft ? [...$draft, 'needs_review' => (int) $draft['version'] !== (int) $case->version] : null;
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function saveDraft(User $actor, string $id, array $input): array public function saveDraft(User $actor, string $id, array $input): array
{ {
$case=$this->desk->case($actor,$id,true); $case = $this->desk->case($actor, $id, true);
abort_if($case->closed_at,409); abort_if($case->closed_at, 409);
$sequence = (int)($input['draft_sequence'] ?? 0); $sequence = (int) ($input['draft_sequence'] ?? 0);
$entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:200','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[]; $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']); unset($input['draft_sequence'],$input['entry_sources']);
abort_unless($sequence > 0, 422); abort_unless($sequence > 0, 422);
$data = $this->input($input); $data = $this->input($input);
if (isset($data['complements'])) app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []); if (isset($data['complements'])) {
foreach ($data['selection'] as $slot=>$key) { app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
if (!$key) continue; }
$ref=$this->reference($key); foreach ($data['selection'] as $slot => $key) {
abort_unless(in_array($ref['kind'],$slot==='opportunity'?['opportunity','business']:[$slot],true),422,'Referência incompatível com a escolha.'); 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) { foreach ($data['opportunity_choices'] ?? [] as $choice) {
$ref = $this->reference($choice['source']); $ref = $this->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422); 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); app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id);
$key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record); $key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record);
if (!empty($choice['territory'])) { if (! empty($choice['territory'])) {
$location = $this->reference($choice['territory']); $location = $this->reference($choice['territory']);
abort_unless($location['kind'] === 'territory', 422); 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) { if ($key) {
$territory=$first['territory'] ?? $data['selection']['territory'] ?? null; abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.');
$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.');
} }
} }
} }
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) { return DB::transaction(function () use ($actor, $id, $data, $sequence, $entrySources) {
$case = $this->desk->case($actor, $id, true, true); $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); $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.'); 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)]); DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload)]);
return ['saved' => true]; return ['saved' => true];
}); });
} }
@@ -93,6 +114,9 @@ final class InvestorContext
return Schema::hasTable('investor_navigation_preferences'); return Schema::hasTable('investor_navigation_preferences');
} }
/**
* @return array<array-key, mixed>
*/
public function state(\stdClass $case): array public function state(\stdClass $case): array
{ {
$payload = $this->desk->decode($case->payload); $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; $zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general'; $context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? ''; $context['fields']['special_zone_id'] = $zone['id'] ?? '';
@@ -120,13 +146,22 @@ final class InvestorContext
return $context; return $context;
} }
/**
* @param array<array-key, mixed> $selection
*/
public function dependencyHash(array $selection): string public function dependencyHash(array $selection): string
{ {
unset($selection['portfolio'], $selection['selection']['portfolio']); 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 hash('sha256', json_encode(Arr::sortRecursive($selection), JSON_THROW_ON_ERROR));
} }
/**
* @return array<array-key, mixed>
*/
public function reference(string $key): array public function reference(string $key): array
{ {
[$kind,$id] = array_pad(explode(':', $key, 2), 2, ''); [$kind,$id] = array_pad(explode(':', $key, 2), 2, '');
@@ -140,7 +175,7 @@ final class InvestorContext
$zoneAnalysis = null; $zoneAnalysis = null;
if ($kind === 'opportunity') { if ($kind === 'opportunity') {
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.'); 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(); $published = Opportunity::published()->whereKey($o->id)->exists();
if (! $published) { if (! $published) {
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.'); 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; $fields['sector'] = $o->sector->name;
} }
$proactive = $published ? app(ProactiveInvestment::class)->publicData($o) : ($draft['proactive'] ?? null); $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. // Text labels do not establish a canonical territorial correspondence.
if ($o->province) { if ($o->province) {
$fields['province'] = $o->province; $fields['province'] = $o->province;
@@ -173,7 +210,7 @@ final class InvestorContext
} }
} elseif ($kind === 'portfolio') { } elseif ($kind === 'portfolio') {
abort_unless(Str::isUuid($id), 422); abort_unless(Str::isUuid($id), 422);
$dossier = \App\Http\Controllers\InvestmentPortfolioController::visible()->findOrFail($id); $dossier = InvestmentPortfolioController::visible()->whereKey($id)->firstOrFail();
$title = $dossier->title; $title = $dossier->title;
$href = '/carteira/'.$id; $href = '/carteira/'.$id;
// Documentary coverage is not the investor's chosen implantation. // 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]; 'geography' => app(InvestmentRelations::class)->dossier($dossier), 'available_opportunity' => false];
} elseif ($kind === 'business') { } elseif ($kind === 'business') {
abort_unless(config('existing-business.enabled') && Str::isUuid($id), 404); 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; $title = $o->public_title;
$href = '/negocios/'.$o->public_slug; $href = '/negocios/'.$o->public_slug;
$version = $o->version_no; $version = $o->version_no;
@@ -198,7 +235,9 @@ final class InvestorContext
if ($location) { if ($location) {
$leaf = $location->province_id; $leaf = $location->province_id;
foreach (['district', 'admin_post', 'locality'] as $level) { 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'}; $leaf = $location->{$level.'_id'};
} }
} }
@@ -208,7 +247,7 @@ final class InvestorContext
} }
} elseif ($kind === 'sector') { } elseif ($kind === 'sector') {
abort_unless(ctype_digit($id), 422); 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; $title = $s->name;
$href = SectorOrganization::href($s->slug); $href = SectorOrganization::href($s->slug);
$version = $s->updated_at?->toIso8601String(); $version = $s->updated_at?->toIso8601String();
@@ -216,14 +255,14 @@ final class InvestorContext
} elseif ($kind === 'territory') { } elseif ($kind === 'territory') {
abort_unless(Str::isUuid($id), 422); abort_unless(Str::isUuid($id), 422);
$t = app(InvestmentGeography::class)->publicReferences()->whereIn('location_type', array_values(InvestmentGeography::FIELDS))->where('id', $id)->first(); $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; $title = $t->name;
$version = $t->version_no; $version = $t->version_no;
$href = '/oportunidades?'.$t->location_type.'_id='.$id; $href = '/oportunidades?'.$t->location_type.'_id='.$id;
$fields = $this->territoryFields($id); $fields = $this->territoryFields($id);
} elseif ($kind === 'park') { } elseif ($kind === 'park') {
abort_unless(Str::isUuid($id), 422); 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; $title = $p->name;
$fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category]; $fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category];
$version = $p->version_no; $version = $p->version_no;
@@ -249,6 +288,9 @@ final class InvestorContext
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))]; return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
} }
/**
* @return array<array-key, mixed>
*/
private function territoryFields(string $id): array private function territoryFields(string $id): array
{ {
$path = app(InvestmentGeography::class)->path($id); $path = app(InvestmentGeography::class)->path($id);
@@ -271,6 +313,9 @@ final class InvestorContext
return $fields; return $fields;
} }
/**
* @return array<array-key, mixed>
*/
public function options(string $query = ''): array public function options(string $query = ''): array
{ {
$query = mb_substr(trim($query), 0, 120); $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'])]; 'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
} }
/**
* @return array<array-key, mixed>
*/
public function current(User $actor): array public function current(User $actor): array
{ {
if (! $this->installed()) { 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]]; return ['installed' => true, 'focus' => ['case_id' => $case->id, 'step_code' => $p->step_code, 'version' => (int) $p->version]];
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function focus(User $actor, array $input): array public function focus(User $actor, array $input): array
{ {
abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.'); abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.');
@@ -344,9 +396,13 @@ final class InvestorContext
}); });
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
private function input(array $input): 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.'); 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(); 'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function preview(User $actor, string $id, array $input): array public function preview(User $actor, string $id, array $input): array
{ {
$data = $this->input($input); $data = $this->input($input);
@@ -397,7 +457,7 @@ final class InvestorContext
if (! array_key_exists('opportunity_choices', $data) && array_key_exists('territory', $data['selection'])) { if (! array_key_exists('opportunity_choices', $data) && array_key_exists('territory', $data['selection'])) {
$source = $data['selection']['opportunity'] ?? ($old['selection']['opportunity']['key'] ?? null); $source = $data['selection']['opportunity'] ?? ($old['selection']['opportunity']['key'] ?? null);
if ($source && str_starts_with($source, 'opportunity:')) { 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, 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.'); '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; $data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null;
// Shared fields concern the first identified opportunity; other sites remain separate. // Shared fields concern the first identified opportunity; other sites remain separate.
$data['selection']['territory'] = $choices[0]['territory']['key'] ?? null; $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 !== []) { } 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.'); abort(422, 'Actualize a selecção identificada de oportunidades; não substitua silenciosamente o conjunto por uma ficha.');
} }
$selection = $old['selection']; $selection = $old['selection'];
foreach ($complements as $complement) { 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) { 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) { foreach ($data['selection'] as $slot => $key) {
@@ -426,16 +492,20 @@ final class InvestorContext
} }
$ref = $this->reference($key); $ref = $this->reference($key);
$expected = $slot === 'opportunity' ? ['opportunity', 'business'] : [$slot]; $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; $selection[$slot] = $ref;
} }
$corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity' $corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail($selection['opportunity']['id'])) : null); ? 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['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'])) { if ($corridor && isset($selection['park'])) {
$eligible = array_column(app(InvestmentCorridorContext::class)->parks($corridor, $selection['territory']['id'] ?? null), 'id'); $eligible = array_column(app(InvestmentCorridorContext::class)->parks($corridor, $selection['territory']['id'] ?? null), 'id');
if (!in_array($selection['park']['id'], $eligible, true)) { 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.'); abort_if(! empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
unset($selection['park']); unset($selection['park']);
} }
} }
@@ -484,14 +554,16 @@ final class InvestorContext
} }
} }
if ($corridor) { 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])) { if (isset($selection['territory']['fields'][$key])) {
$fields[$key]=(string)$selection['territory']['fields'][$key]; $fields[$key] = (string) $selection['territory']['fields'][$key];
$provenance[$key]=Arr::except($selection['territory'],['fields']); $provenance[$key] = Arr::except($selection['territory'], ['fields']);
} else unset($fields[$key],$provenance[$key]); } else {
unset($fields[$key],$provenance[$key]);
}
} }
$fields['special_zone_id']=$selection['park']['id']??''; $fields['special_zone_id'] = $selection['park']['id'] ?? '';
$fields['investment_regime']=$selection['park']['fields']['investment_regime']??'general'; $fields['investment_regime'] = $selection['park']['fields']['investment_regime'] ?? 'general';
} }
$replaced = []; $replaced = [];
foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) { foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) {
@@ -505,11 +577,16 @@ final class InvestorContext
} }
} }
$changedFields = array_column($replaced, 'field'); $changedFields = array_column($replaced, 'field');
if ($complements !== ($old['complements'] ?? [])) $changed[] = 'complements'; if ($complements !== ($old['complements'] ?? [])) {
$changed[] = 'complements';
}
$choicesChanged = $choices !== ($old['opportunity_choices'] ?? []); $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']??[]); $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'])); if ($choiceInputsChanged) {
elseif ($choicesChanged) $changed[] = 'portfolio_origins'; $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'])); $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; $dependencyChanged = $choiceInputsChanged || in_array('complements', $changed, true) || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS); $privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
@@ -524,7 +601,9 @@ final class InvestorContext
if ($privateConditionsChanged) { if ($privateConditionsChanged) {
$steps = array_values(array_unique([...$steps, 'S02', 'S05', 'S06', 'S07'])); $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; $privatePreparation = null;
if (app(PrivateInvestmentPreparation::class)->available()) { if (app(PrivateInvestmentPreparation::class)->available()) {
$docs = $this->preparationDocuments($id); $docs = $this->preparationDocuments($id);
@@ -543,6 +622,10 @@ final class InvestorContext
return [...$plan, 'preview_hash' => $hash]; return [...$plan, 'preview_hash' => $hash];
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function commit(User $actor, string $id, array $input): array public function commit(User $actor, string $id, array $input): array
{ {
abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.'); abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.');
@@ -586,7 +669,7 @@ final class InvestorContext
} }
} }
$payload['investor_path']['context'] = $context; $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()]); 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')]); $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]; $result = ['case_id' => $id, 'version' => (int) $case->version + 1, 'revision' => $revision, 'replayed' => false];
@@ -596,6 +679,9 @@ final class InvestorContext
}, 3); }, 3);
} }
/**
* @return array<array-key, mixed>
*/
public function view(User $actor, string $id, ?string $opportunity = null): array public function view(User $actor, string $id, ?string $opportunity = null): array
{ {
$case = $this->desk->case($actor, $id); $case = $this->desk->case($actor, $id);
@@ -622,21 +708,28 @@ final class InvestorContext
$prefill = $this->prefill($ctx, $opportunity); $prefill = $this->prefill($ctx, $opportunity);
$saved = $this->desk->decode($case->payload); $saved = $this->desk->decode($case->payload);
if (!empty($saved['reuse_consent'])) { if (! empty($saved['reuse_consent'])) {
$contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== ''); $contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn ($v) => is_string($v) && $v !== '');
$prefill['simulator'] = [...$contacts, ...$prefill['simulator']]; $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']), 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'], '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.']; 'prefill' => $prefill, 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
} }
/**
* @return array<array-key, mixed>
*/
public function preparationDocuments(string $id): 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 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<array-key, mixed>
*/
public function landPreparation(\stdClass $case, ?string $opportunity = null): array public function landPreparation(\stdClass $case, ?string $opportunity = null): array
{ {
$payload = $this->desk->decode($case->payload); $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'] ?? '')]; 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<array-key, mixed> $ctx
* @return array<array-key, mixed>
*/
public function prefill(array $ctx, ?string $opportunity = null): array public function prefill(array $ctx, ?string $opportunity = null): array
{ {
$original = $ctx; $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']; $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') { if (($ctx['land_preparation']['need'] ?? null) === 'yes') {
$tenure = ['duat' => ['duat', 'DUAT'], 'lease' => ['leased', 'Arrendado']][$f['site_tenure'] ?? ''] ?? null; $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. // 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 (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) { 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; $zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([ $sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'], 'version' => 1, 'context_revision' => $ctx['revision'],
@@ -675,12 +778,13 @@ final class InvestorContext
'portfolio_origin' => $ctx['selection']['portfolio'] ?? null, 'portfolio_origin' => $ctx['selection']['portfolio'] ?? null,
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null, 'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'], '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'] ?? [], 'complements' => $ctx['complements'] ?? [],
'requirements_assessment' => $ctx['requirements_assessment'] ?? null, 'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
'path' => $ctx['path_applicability'] ?? null, 'path' => $ctx['path_applicability'] ?? null,
'land_preparation' => $ctx['land_preparation'] ?? 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, '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']), '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.', '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); $scoped = app(InvestmentConsumerScope::class)->context($original, $opportunity);
$f = $scoped['fields']; $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), 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), '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)]; 'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
+6 -3
View File
@@ -20,7 +20,7 @@ final class InvestorContextConsumers
} }
} }
return 0; return 0;
} }
public function landRevision(string $id): int public function landRevision(string $id): int
@@ -32,7 +32,7 @@ return 0;
} }
} }
return 0; return 0;
} }
public function submissionRevision(string $id): int public function submissionRevision(string $id): int
@@ -44,9 +44,12 @@ return 0;
} }
} }
return 0; return 0;
} }
/**
* @param array<array-key, mixed> $input
*/
public function assertReviewed(User $actor, string $id, array $input, int $bound): int public function assertReviewed(User $actor, string $id, array $input, int $bound): int
{ {
$case = app(VirtualDesk::class)->case($actor, $id, true, true); $case = app(VirtualDesk::class)->case($actor, $id, true, true);
+14 -3
View File
@@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@@ -15,6 +16,9 @@ final class InvestorContextInstallation
{ {
public const SOURCE = 'investor-context-installation'; public const SOURCE = 'investor-context-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): 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']; 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<array-key, mixed>
*/
public function preview(User $actor): array public function preview(User $actor): array
{ {
$this->authorize($actor); $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'); $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'); $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 $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(); && ($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) { foreach (['dump', 'files'] as $kind) {
$p = $proof[$kind.'_path'] ?? ''; $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) { if (! $isolated && ! $fresh) {
$reasons[] = 'Autorização específica, hashes e backup/restauro/arranque frescos desta candidata necessários.'; $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'))]; return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array public function apply(User $actor, array $input): array
{ {
$this->authorize($actor, true); $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(); $data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) { return DB::transaction(function () use ($actor, $data) {
+8 -4
View File
@@ -6,6 +6,7 @@ use App\Models\CrmTask;
use App\Models\LandAssistanceCase; use App\Models\LandAssistanceCase;
use App\Models\SimulationFormDraft; use App\Models\SimulationFormDraft;
use App\Models\User; use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -152,7 +153,7 @@ final class InvestorPath
$landCase = (int) $case->user_id === $actor->id $landCase = (int) $case->user_id === $actor->id
? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get() ? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get()
->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id ->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; $preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null;
if ($preparation && $preparation['enabled']) { 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'], '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, 'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices, '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, '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, '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, '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.'); 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()]; $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')); $stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail'));
if ($key) $stored['opportunities'][$key] = $detail; if ($key) {
else $stored = [...$stored, ...$detail]; $stored['opportunities'][$key] = $detail;
} else {
$stored = [...$stored, ...$detail];
}
DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored), 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'), 'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'),
'version_no' => $step['version'] + 1, 'updated_at' => now()]); 'version_no' => $step['version'] + 1, 'updated_at' => now()]);
+31 -14
View File
@@ -5,13 +5,18 @@ namespace App\Services;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Models\User; use App\Models\User;
use App\Support\CheckedPattern;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use DOMNode; use DOMNode;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** A reading adapter over the existing library, never a second content/opportunity register. */ /** A reading adapter over the existing library, never a second content/opportunity register. */
final class LegacyContent 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']); $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'])); $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; ? DB::table('portal_resources')->where('id', $row->resource_id)->value('editorial_content') : null;
if ($editorial) { if ($editorial) {
$adaptation = json_decode($editorial, true, 64, JSON_THROW_ON_ERROR); $adaptation = json_decode($editorial, true, 64, JSON_THROW_ON_ERROR);
$localHtml = implode('', array_map(fn ($p) => '<p>'.nl2br(e($p)).'</p>', preg_split('/\n\s*\n/', $adaptation['body']))); $localHtml = implode('', array_map(fn ($p) => '<p>'.nl2br(e($p)).'</p>', CheckedPattern::split('/\n\s*\n/', $adaptation['body'])));
$source['title'] = $row->title; $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(); $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 = []; $relatedArticles = [];
if ($includeRelated) { if ($includeRelated) {
foreach ($this->related($identity['group'], $slug) as $item) { 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 { try {
$relatedArticles[] = $this->article($item['slug'], false); $relatedArticles[] = $this->article($item['slug'], false);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { } catch (HttpException $error) {
if ($error->getStatusCode() !== 404) throw $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')) { && 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.'; 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('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs); if (preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs) === false) {
foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) { throw new \RuntimeException('Invalid paragraph encoding; original preserved.');
$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);
} }
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')); 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. */ /** Resolve a library identity, preserving the already stored source URLs and versions.
/** @return array<string, mixed> */ * @return array<string, mixed> */
public function articleForResource(int $resourceId): array public function articleForResource(int $resourceId): array
{ {
abort_unless($this->available(), 404); abort_unless($this->available(), 404);
@@ -239,8 +253,10 @@ final class LegacyContent
}); });
} }
/** Resolve explicit transport aliases without changing original IDs, versions or relations. */ /** Resolve explicit transport aliases without changing original IDs, versions or relations.
private function identityResources(): \Illuminate\Support\Collection * @return Collection<int, \stdClass>
*/
private function identityResources(): Collection
{ {
$identities = array_column(config('legacy_content.articles', []), 'external_id'); $identities = array_column(config('legacy_content.articles', []), 'external_id');
$aliases = config('legacy_content_aliases', []); $aliases = config('legacy_content_aliases', []);
@@ -269,6 +285,7 @@ final class LegacyContent
$row->legacy_identity = $identity; $row->legacy_identity = $identity;
$resolved->put($identity, $row); $resolved->put($identity, $row);
} }
return $resolved; return $resolved;
} }
+10 -3
View File
@@ -17,7 +17,10 @@ class OpportunityCanonicalContract
{ {
public const BASE_CATEGORIES = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise']; 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<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
public function deliveryBlockers(array $payload): array public function deliveryBlockers(array $payload): array
{ {
$errors = []; $errors = [];
@@ -44,7 +47,10 @@ class OpportunityCanonicalContract
public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1'; public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1';
/** @return array<string,mixed> */ /**
* @param array<array-key, mixed> $payload
* @return array<string,mixed>
*/
public function schema(bool $draft = false, array $payload = []): array public function schema(bool $draft = false, array $payload = []): array
{ {
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json')); $bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
@@ -173,6 +179,7 @@ class OpportunityCanonicalContract
$args = $node->args(); $args = $node->args();
if ($node->keyword() === 'type' && in_array($prefix, ['canonical.initiative.responsible_name', 'canonical.initiative.coverage_statement'], true)) { 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.'; $errors[$prefix][] = 'Informação em falta: preencha este campo antes de submeter.';
return; 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.'); && hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.');
if ($file) { if ($file) {
// Verify actual bytes/cipher integrity as well as the recorded scan hash. // 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); app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256); $row->sha256 = trim($file->sha256);
} }
+58 -11
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController; use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\User; use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -40,6 +41,10 @@ class OpportunityClassification
public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired']; public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired'];
/**
* @param array<array-key, mixed> $schema
* @return array<array-key, mixed>
*/
public function extend(array $schema): array public function extend(array $schema): array
{ {
$fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)], $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')); $query->whereIn('cluster_id', $this->targets('cluster')->select('id'));
} }
if ($kind === 'location') { 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)) { 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')); 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 app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind);
} }
/**
* @return array<array-key, mixed>
*/
public function options(): array public function options(): array
{ {
$targets = []; $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]; 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<array-key, mixed> $payload
*/
public function validate(array $payload, Opportunity $record): void public function validate(array $payload, Opportunity $record): void
{ {
$previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : []; $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<array-key, mixed> $previousRows
* @param array<array-key, mixed> $row
*/
public function canRetainReference(string $kind, string $id, array $row, array $previousRows): bool public function canRetainReference(string $kind, string $id, array $row, array $previousRows): bool
{ {
if ($this->targets($kind)->where('id', $id)->exists()) { return true; } if ($this->targets($kind)->where('id', $id)->exists()) {
if (! app(EconomicReferenceCatalog::class)->historicalTarget($kind, $id)) { return false; } 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'])); $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))); return collect($previousRows)->contains(fn ($old) => OpportunityCanonicalValue::hash($content($old)) === OpportunityCanonicalValue::hash($content($row)));
} }
/**
* @param array<array-key, mixed> $row
*/
public function hash(array $row): string public function hash(array $row): string
{ {
// Client identity/status cannot certify a relation. Object-order normalization is reused. // 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)); && ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true));
} }
/**
* @param array<array-key, mixed> $row
*/
public function state(Opportunity $record, array $row): string public function state(Opportunity $record, array $row): string
{ {
$decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null; $decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null;
@@ -219,7 +244,7 @@ class OpportunityClassification
return $decision['state']; return $decision['state'];
} }
if ($decision) { if ($decision) {
$reviewer = User::find($decision['actor_id']); $reviewer = User::whereKey($decision['actor_id'])->first();
$c = $row['classification']; $c = $row['classification'];
if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) { if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) {
return 'in_validation'; return 'in_validation';
@@ -239,6 +264,9 @@ class OpportunityClassification
return $row['classification']['requested_state'] ?? 'preliminary'; return $row['classification']['requested_state'] ?? 'preliminary';
} }
/**
* @param array<array-key, mixed> $row
*/
private function evidenceHash(array $row): ?string private function evidenceHash(array $row): ?string
{ {
$id = $row['classification']['document_id'] ?? null; $id = $row['classification']['document_id'] ?? null;
@@ -251,6 +279,9 @@ class OpportunityClassification
return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]); return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]);
} }
/**
* @return array<array-key, mixed>
*/
public function listing(User $actor, Opportunity $record): array public function listing(User $actor, Opportunity $record): array
{ {
app(PortalProfileAccess::class)->authorize($actor, 'view', $record); 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), 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'] ?? []]; 'can_review' => $this->canReview($actor, $record, $dimension), 'history' => $record->workflow_context['classification_decisions'][$this->hash($row)]['history'] ?? []];
}, $rows); }, $rows);
} }
/**
* @param array<array-key, mixed> $input
*/
public function review(User $actor, Opportunity $record, array $input): Opportunity 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(); $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)) { if (in_array($data['state'], ['revoked', 'archived'], true)) {
$rows = [...$rows, ...($record->workflow_context['publication']['classification'] ?? [])]; $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.'); abort_unless($row, 409, 'Associação inexistente nesta versão.');
$c = $row['classification']; $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.'); 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<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
public function publication(array $payload): 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_values(array_filter($payload['relations'] ?? [], fn ($r) => isset($r['classification']) && ($r['classification']['disclosure'] ?? 'restricted') === 'public'));
} }
/**
* @return array<array-key, mixed>
*/
public function publicRows(Opportunity $record): 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') { 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. // 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]}, $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, '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, 'physical_category' => $target->physical_category ?? null, 'legal_regime' => $target->legal_regime ?? null,
'state' => $state, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? 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, 'authority_id' => $c['authority_id'] ?? null, 'external_status' => $c['external_status'] ?? 'not_assessed', 'availability_confirmed' => false,
@@ -353,7 +393,10 @@ class OpportunityClassification
return $rows; 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<array-key, mixed> $filters
* @param Builder<Opportunity> $query
*/
public function constrain(Builder $query, array $filters): void public function constrain(Builder $query, array $filters): void
{ {
$keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id']; $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']); $matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']);
} }
if (! empty($filters['subsector_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) { if ($matches) {
$ids[] = $record->id; $ids[] = $record->id;
@@ -393,9 +436,13 @@ class OpportunityClassification
$query->whereIn('opportunities.id', $ids); $query->whereIn('opportunities.id', $ids);
} }
/**
* @return array<array-key, mixed>
*/
public function duplicates(User $actor, Opportunity $record): array public function duplicates(User $actor, Opportunity $record): array
{ {
app(PortalProfileAccess::class)->authorize($actor, 'view', $record); app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
// Exact-title candidates only within the actor's existing visibility; never merge or reveal another dossier. // 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) 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']) ->whereRaw('lower(trim(title))=lower(trim(?))', [$record->title])->limit(10)->get(['id', 'reference', 'title'])
+6 -2
View File
@@ -14,6 +14,10 @@ use Illuminate\Validation\ValidationException;
/** Reuse the CRM master without manufacturing an interest, lead or consent. */ /** Reuse the CRM master without manufacturing an interest, lead or consent. */
class OpportunityContacts class OpportunityContacts
{ {
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function save(User $actor, int $id, array $input): array public function save(User $actor, int $id, array $input): array
{ {
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
@@ -63,7 +67,7 @@ class OpportunityContacts
$investors = Investor::where('organization_id', $record->organization_id)->whereIn('status', ['prospect', 'active'])->lockForUpdate()->get(); $investors = Investor::where('organization_id', $record->organization_id)->whereIn('status', ['prospect', 'active'])->lockForUpdate()->get();
if (! empty($data['investor_id'])) { if (! empty($data['investor_id'])) {
$investor = $investors->firstWhere('id', $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) { } elseif ($investors->count() === 1) {
$investor = $investors->first(); $investor = $investors->first();
} elseif ($investors->count() > 1) { } elseif ($investors->count() > 1) {
@@ -73,7 +77,7 @@ class OpportunityContacts
throw ValidationException::withMessages(['investor_type' => 'Indique a classificação do promotor no cadastro existente.']); 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(); $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; $investor = new Investor;
PortalAudit::save($investor, ['organization_id' => $organization->id, 'legal_name' => $organization->name, PortalAudit::save($investor, ['organization_id' => $organization->id, 'legal_name' => $organization->name,
'investor_type' => $data['investor_type'], 'status' => 'prospect', 'source_system' => 'portal-promoter-master', 'investor_type' => $data['investor_type'], 'status' => 'prospect', 'source_system' => 'portal-promoter-master',
+16 -7
View File
@@ -2,11 +2,15 @@
namespace App\Services; namespace App\Services;
use App\Models\Opportunity; use App\Support\StructuredData;
/** Read-only grouping of the existing public catalogue. */ /** Read-only grouping of the existing public catalogue. */
final class OpportunityCorridorExplorer final class OpportunityCorridorExplorer
{ {
/**
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
*/
public function groups(array $filters): array public function groups(array $filters): array
{ {
$catalogue = app(InvestmentCatalogue::class); $catalogue = app(InvestmentCatalogue::class);
@@ -14,7 +18,7 @@ final class OpportunityCorridorExplorer
foreach ($catalogue->query($filters)->get() as $record) { foreach ($catalogue->query($filters)->get() as $record) {
$context = $catalogue->context($record->id); $context = $catalogue->context($record->id);
$classification = $context['classification'] ?? []; $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) { foreach ($classification as $relation) {
if ($relation['target_type'] === 'corridor' && ! in_array($relation['purpose'], ['proximity', 'administrative_boundary'], true)) { if ($relation['target_type'] === 'corridor' && ! in_array($relation['purpose'], ['proximity', 'administrative_boundary'], true)) {
$corridors['corridor:'.$relation['target_id']] = $relation['label']; $corridors['corridor:'.$relation['target_id']] = $relation['label'];
@@ -27,12 +31,16 @@ final class OpportunityCorridorExplorer
$corridors['source:'.hash('sha256', $source['corridor'])] = $source['corridor']; $corridors['source:'.hash('sha256', $source['corridor'])] = $source['corridor'];
$chain = $source['chain']; $chain = $source['chain'];
} }
$chains = collect($classification)->where('target_type', 'chain')->pluck('label')->all(); $chains = collect(StructuredData::collectionItems($classification))->where('target_type', 'chain')->pluck('label')->all();
if ($chain) $chains[] = $chain; if ($chain) {
$stages = collect($classification)->pluck('production_stage')->filter()->unique()->values()->all(); $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. // Keep the source's actual activity description rather than inventing stage labels.
if ($stages === [] && $chain && ! empty($source['summary'])) $stages = [$source['summary']]; if ($stages === [] && $chain && ! empty($source['summary'])) {
$path = collect($context['primary_territory_path'] ?? []); $stages = [$source['summary']];
}
$path = collect(StructuredData::collectionItems($context['primary_territory_path'] ?? []));
$province = $path->firstWhere('location_type', 'province')->name ?? $record->province; $province = $path->firstWhere('location_type', 'province')->name ?? $record->province;
$district = $path->firstWhere('location_type', 'district')->name ?? $record->district; $district = $path->firstWhere('location_type', 'district')->name ?? $record->district;
$contextProvinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record); $contextProvinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record);
@@ -53,6 +61,7 @@ final class OpportunityCorridorExplorer
} }
unset($group); unset($group);
uasort($groups, fn ($a, $b) => ($a['id'] === 'other' ? 1 : 0) <=> ($b['id'] === 'other' ? 1 : 0) ?: strnatcasecmp($a['name'], $b['name'])); uasort($groups, fn ($a, $b) => ($a['id'] === 'other' ? 1 : 0) <=> ($b['id'] === 'other' ? 1 : 0) ?: strnatcasecmp($a['name'], $b['name']));
return array_values($groups); return array_values($groups);
} }
} }
+2 -1
View File
@@ -45,7 +45,7 @@ class OpportunityImageProcessor
$previousDerivatives = $previousPayload['derivatives'] ?? []; $previousDerivatives = $previousPayload['derivatives'] ?? [];
$expectedWidths = array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280])); $expectedWidths = array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280]));
if (($previousPayload['derived_from_sha256'] ?? null) === trim($doc->sha256) 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; $reusable = true;
$retry = []; $retry = [];
foreach ($previousDerivatives as $derivative) { foreach ($previousDerivatives as $derivative) {
@@ -65,6 +65,7 @@ class OpportunityImageProcessor
} }
if ($reusable) { if ($reusable) {
$documents = $retry; $documents = $retry;
return; // Retry scans of the same streams, without new files, versions or audit decisions. return; // Retry scans of the same streams, without new files, versions or audit decisions.
} }
} }
@@ -11,17 +11,28 @@ class OpportunityInitiativeApplicability
'unit_investment' => ['total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators'], 'unit_investment' => ['total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators'],
]; ];
/**
* @param array<array-key, mixed> $p
*/
public function enabled(array $p): bool public function enabled(array $p): bool
{ {
return ($p['opportunity_category'] ?? null) === 'investment_project' return ($p['opportunity_category'] ?? null) === 'investment_project'
&& in_array($p['initiative']['kind'] ?? null, ['program', 'portfolio'], true); && in_array($p['initiative']['kind'] ?? null, ['program', 'portfolio'], true);
} }
/**
* @param array<array-key, mixed> $p
*/
public function applicable(array $p, string $group): bool public function applicable(array $p, string $group): bool
{ {
return ! $this->enabled($p) || ($p['initiative']['applicability'][$group] ?? 'missing') !== 'not_applicable'; return ! $this->enabled($p) || ($p['initiative']['applicability'][$group] ?? 'missing') !== 'not_applicable';
} }
/**
* @param array<array-key, mixed> $payload
* @param array<array-key, mixed> $schema
* @return array<array-key, mixed>
*/
public function schema(array $schema, array $payload): array public function schema(array $schema, array $payload): array
{ {
$base = &$schema['$defs']['opportunityCreate']; $base = &$schema['$defs']['opportunityCreate'];
@@ -42,6 +53,7 @@ class OpportunityInitiativeApplicability
if (isset($payload['initiative'])) { if (isset($payload['initiative'])) {
$base['allOf'][] = ['properties' => ['opportunity_category' => ['const' => 'investment_project']]]; $base['allOf'][] = ['properties' => ['opportunity_category' => ['const' => 'investment_project']]];
} }
return $schema; return $schema;
} }
$ignored = []; $ignored = [];
@@ -73,9 +85,14 @@ class OpportunityInitiativeApplicability
if (! $this->applicable($payload, 'physical_site')) { if (! $this->applicable($payload, 'physical_site')) {
unset($base['properties']['locations']['minItems']); unset($base['properties']['locations']['minItems']);
} }
return $schema; return $schema;
} }
/**
* @param array<array-key, mixed> $errors
* @param array<array-key, mixed> $p
*/
public function validate(array $p, array &$errors): void public function validate(array $p, array &$errors): void
{ {
if (! $this->enabled($p)) { if (! $this->enabled($p)) {
@@ -91,4 +108,3 @@ class OpportunityInitiativeApplicability
} }
} }
} }
+26 -8
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; 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. */ /** An existing opportunity resource references the original document. No binary or permission is copied. */
class OpportunityLibrarySupport class OpportunityLibrarySupport
{ {
/**
* @return array<array-key, mixed>
*/
public function choices(User $actor): 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(); 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') return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted')
@@ -24,18 +28,21 @@ class OpportunityLibrarySupport
->whereIn('d.data_classification', ['internal', 'public']) ->whereIn('d.data_classification', ['internal', 'public'])
->when(! $actor->can('manage-portal'), function ($query) use ($actor) { ->when(! $actor->can('manage-portal'), function ($query) use ($actor) {
$query->where(function ($scope) 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()) { 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)) { 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<array-key, mixed> $input
*/
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass
{ {
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
@@ -45,12 +52,13 @@ class OpportunityLibrarySupport
$opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail(); $opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail();
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); 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']); $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(); $existing = DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->where('document_id', $source->original_id)->first();
if ($existing) { if ($existing) {
$payload = json_decode($existing->payload, true); $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['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.'); 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; 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.'); 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))])]); 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]);
$opportunity->management_version++; $opportunity->management_version++;
$opportunity->save(); $opportunity->save();
return $after; return $after;
}); });
} }
@@ -79,19 +88,28 @@ class OpportunityLibrarySupport
{ {
$payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload; $payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload;
$pin = $payload['library_source'] ?? null; $pin = $payload['library_source'] ?? null;
if (! $pin) return; if (! $pin) {
return;
}
$source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first(); $source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first();
abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted' abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted'
&& in_array($document->data_classification, ['internal', 'public'], true) && 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.'); && $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<array-key, mixed>
*/
public function snapshot(User $actor, Opportunity $opportunity): array public function snapshot(User $actor, Opportunity $opportunity): array
{ {
$access = app(PortalProfileAccess::class); $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) { 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']; $pin = $r->payload['library_source'];
return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version, return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version,
'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id, 'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id,
'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']]; 'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']];
+10 -6
View File
@@ -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.'); 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) { } 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 { } else {
abort_unless($access->legacyWriteAllowed($actor), 403, 'O papel v1 não usa a criação legada para contornar o seu âmbito.'); 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']); $this->check($record, $data['etag']);
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.'); 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(); $before = $record->getAttributes();
$previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : []; $previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : [];
$changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])); $changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS]));
@@ -172,12 +174,12 @@ class OpportunityManagement
$record->management_version = 1; $record->management_version = 1;
$record->estimated_investment_currency = null; $record->estimated_investment_currency = null;
if ($profileCreation) { 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->owner_user_id = $actor->id;
$record->organization_id = $data['organization_id'] ?? null; $record->organization_id = $data['organization_id'] ?? null;
$record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]]; $record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]];
if (($internal=$access->internalAssignment($actor,$data['opportunity_category'] ?? '')) !== null) { 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]; $record->workflow_context = [...$record->workflow_context, 'operator_organization_id' => $internal->organization_id, 'internal_assignment_id' => $internal->id];
} }
} }
} else { } else {
@@ -227,7 +229,9 @@ class OpportunityManagement
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail(); $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.'); 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']); $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(); $before = $record->getAttributes();
if ($data['action'] === 'restore') { if ($data['action'] === 'restore') {
abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.'); abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.');
+10 -2
View File
@@ -2,6 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
/** Catalogue versions are not interchangeable: historical positions keep their meaning. */ /** 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 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<array-key, mixed>
*/
public static function searchOptions(): array public static function searchOptions(): array
{ {
$options = []; $options = [];
@@ -34,10 +38,14 @@ final class OpportunityMaturity
foreach (self::LEGACY_LABELS as $level => $label) { foreach (self::LEGACY_LABELS as $level => $label) {
$options[] = ['value' => 'historical:'.$level, 'label' => $label.' — registo histórico', 'group' => 'Classificações históricas']; $options[] = ['value' => 'historical:'.$level, 'label' => $label.' — registo histórico', 'group' => 'Classificações históricas'];
} }
return $options; return $options;
} }
public static function constrainSearch(\Illuminate\Database\Eloquent\Builder $query, string $selection): void /**
* @param Builder<Opportunity> $query
*/
public static function constrainSearch(Builder $query, string $selection): void
{ {
[$kind, $value] = explode(':', $selection, 2); [$kind, $value] = explode(':', $selection, 2);
if ($kind === 'stage') { if ($kind === 'stage') {
+59 -15
View File
@@ -3,7 +3,9 @@
namespace App\Services; namespace App\Services;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -14,16 +16,22 @@ use Illuminate\Validation\Rule;
final class OpportunityPromotion final class OpportunityPromotion
{ {
public const PURPOSE = 'promotional_preparation'; 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 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 public function allowed(User $actor, Opportunity $record): bool
{ {
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
if ($record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record) 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. // 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); $internal = $access->internalAssignment($actor, $record->opportunity_category);
return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id)) return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id))
|| ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) || ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'publish', $record->id)); && $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.'); 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)) { 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<array-key, mixed>
*/
public function editor(User $actor, Opportunity $record): array public function editor(User $actor, Opportunity $record): array
{ {
abort_unless($this->allowed($actor, $record), 403); abort_unless($this->allowed($actor, $record), 403);
@@ -47,15 +58,20 @@ final class OpportunityPromotion
'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '', '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() : [], 'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [],
'sector_id' => $record->sector_id]; 'sector_id' => $record->sector_id];
return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record), 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', '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), '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']), 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']),
'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication') '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'])]; ->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])];
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function preview(User $actor, Opportunity $record, array $input): array public function preview(User $actor, Opportunity $record, array $input): array
{ {
$this->authorize($actor, $record); $this->authorize($actor, $record);
@@ -64,11 +80,13 @@ final class OpportunityPromotion
'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500', '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', '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', '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', 'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
])->validate(); ])->validate();
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.'); 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 = []; $paths = [];
foreach ($data['territory_ids'] ?? [] as $id) { foreach ($data['territory_ids'] ?? [] as $id) {
$path = app(InvestmentGeography::class)->path($id, true); $path = app(InvestmentGeography::class)->path($id, true);
@@ -84,22 +102,38 @@ final class OpportunityPromotion
$projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body, $projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body,
'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE, '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)]; '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' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) {
if ($data['action'] === 'disclose') abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.'); $projection['proactive'] = $proactive;
if ($data['action'] === 'withdraw') $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])]; $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'], $payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'],
'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp]; '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)), 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]; 'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false];
} }
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function confirm(User $actor, int $id, array $input): array public function confirm(User $actor, int $id, array $input): array
{ {
$data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid', $data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid',
'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate(); '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); } try {
catch (\Throwable) { abort(422, 'Prévia inválida. Prepare outra prévia.'); } $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); abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403);
return DB::transaction(function () use ($actor, $id, $data, $preview) { return DB::transaction(function () use ($actor, $id, $data, $preview) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]); DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]);
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
@@ -110,6 +144,7 @@ final class OpportunityPromotion
if ($replay) { if ($replay) {
$prior = json_decode($replay->after_data, true); $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.'); 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.']; 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.'); 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'; $action = 'promotional_summary_withdrawn';
} else { } else {
$p = $preview['projection']; $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 = []; $paths = [];
foreach ($preview['input']['territory_ids'] ?? [] as $territory) { foreach ($preview['input']['territory_ids'] ?? [] as $territory) {
$path = app(InvestmentGeography::class)->path($territory, true); $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, $context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id,
'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true]; 'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true];
$action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published'; $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 (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) {
foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) $record->{$field} = null; $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] ?? []; $main = $paths[0] ?? [];
$record->primary_territory_id = $main ? end($main)->id : null; $record->primary_territory_id = $main ? end($main)->id : null;
$record->province = collect($main)->firstWhere('location_type', 'province')?->name; $record->province = collect($main)->firstWhere('location_type', 'province')?->name;
$record->district = collect($main)->firstWhere('location_type', 'district')?->name; $record->district = collect($main)->firstWhere('location_type', 'district')?->name;
$record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified'); $record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified');
DB::table('opportunity_locations')->where('opportunity_id', $id)->delete(); 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->status = 'published';
$record->is_featured = false; $record->is_featured = false;
$record->last_validated_at = null; $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, 'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest,
'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false, 'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false,
'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]); '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.']; 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.'];
}); });
} }
+13 -4
View File
@@ -10,7 +10,9 @@ final class OpportunityPublicText
public static function description(Opportunity $record, string $text): string public static function description(Opportunity $record, string $text): string
{ {
$source = data_get($record->workflow_context, 'corridor_agribusiness'); $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; $hash = CorridorAgribusinessOpportunities::SOURCE_HASH;
$sourceLine = 'Fonte: '.$source['source'].'; SHA-256 '.$hash; $sourceLine = 'Fonte: '.$source['source'].'; SHA-256 '.$hash;
@@ -20,13 +22,20 @@ final class OpportunityPublicText
$paragraph = trim($paragraph); $paragraph = trim($paragraph);
if (in_array($paragraph, [$sourceLine, $sourceLine.'.', 'Edição: Não indicada.', 'Data-base: Não indicada.', if (in_array($paragraph, [$sourceLine, $sourceLine.'.', 'Edição: Não indicada.', 'Data-base: Não indicada.',
'Limitações e lacunas: '.CorridorAgribusinessOpportunities::LIMITATIONS, 'Limitações e lacunas: '.CorridorAgribusinessOpportunities::LIMITATIONS,
OpportunityPromotion::NOTICE], true)) continue; OpportunityPromotion::NOTICE], true)) {
if ($paragraph === 'Enquadramento documental: '.$source['corridor'].'.') $paragraph = $source['corridor'].'.'; continue;
}
if ($paragraph === 'Enquadramento documental: '.$source['corridor'].'.') {
$paragraph = $source['corridor'].'.';
}
if ($paragraph === CorridorAgribusinessOpportunities::LIMITATIONS) { 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.'; $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); return implode("\n\n", $result);
} }
} }
@@ -7,22 +7,33 @@ use App\Models\Opportunity;
/** Read-only presentation of existing references. Does not reconcile, confirm or save data. */ /** Read-only presentation of existing references. Does not reconcile, confirm or save data. */
class OpportunityTerritorialPresentation class OpportunityTerritorialPresentation
{ {
/**
* @param array<array-key, mixed> $locations
* @return array<array-key, mixed>
*/
public function describe(Opportunity $record, array $locations): 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))); $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'); $mainIds = array_column($path, 'id', 'location_type');
$differences = []; $differences = [];
foreach ($locations as $location) { 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; $p = $location->payload;
$different = false; $different = false;
foreach ($mainIds as $level => $id) if (!empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) $different = true; foreach ($mainIds as $level => $id) {
$foreign = !empty($p['country_code']) && $p['country_code'] !== 'MZ'; if (! empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) {
$different = true;
}
}
$foreign = ! empty($p['country_code']) && $p['country_code'] !== 'MZ';
if ($foreign || $different) { 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.']; '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]; return ['path' => $path, 'differences' => $differences];
} }
} }
+104 -68
View File
@@ -11,95 +11,131 @@ use Illuminate\Validation\Rule;
/** Owner-directed staff assignments use existing identities, organizations and grants. */ /** Owner-directed staff assignments use existing identities, organizations and grants. */
class PortalInternalDesignation class PortalInternalDesignation
{ {
/**
* @return array<array-key, mixed>
*/
public function organizations(): 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') 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(); ->get(['id', 'name', 'external_id', 'version_no'])->map(fn ($r) => $this->organization($r->id))->filter()->values()->all();
} }
/**
* @return array<array-key, mixed>|null
*/
public function organization(?string $id): ?array public function organization(?string $id): ?array
{ {
if ($id===null) return null; if ($id === null) {
$r=DB::table('organizations')->where('id',$id)->whereNull('deleted_at')->first(); return null;
if (!$r) return null; }
$event=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered') $r = DB::table('organizations')->where('id', $id)->whereNull('deleted_at')->first();
->where('after_data->organization_id',$id)->orderByDesc('occurred_at')->first(); if (! $r) {
if (!$event) return null; 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; $event = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered')
return ['id'=>$r->id,'version'=>$r->version_no,...$data['organization'],'source_reference'=>$data['source_reference']]; ->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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function registerOrganization(User $actor, array $input): array
{ {
app(PortalProfileAdministration::class)->administrative($actor); 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}$/', $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', '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(); 'function' => 'required|in:portal_owner_operator', 'source_reference' => 'required|string|min:20|max:1000'])->validate();
return DB::transaction(function()use($actor,$v){
return DB::transaction(function () use ($actor, $v) {
DB::select('SELECT pg_advisory_xact_lock(101026,40001)'); DB::select('SELECT pg_advisory_xact_lock(101026,40001)');
$payload=array_intersect_key($v,array_flip(['name','nuit','contact','director','function'])); $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(); $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){ if ($existingEvent) {
$e=json_decode($existingEvent->after_data,true); $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.'); 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.'];
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'])]); $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(); $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.'); abort_if($matches->count() > 1, 409, 'Correspondência ambígua no cadastro mestre; não criar duplicado.');
$r=$matches->first(); $r = $matches->first();
if($r){ 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.'); 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); $known = $this->organization($r->id);
if($known){ if ($known) {
abort_unless(array_intersect_key($known,$payload)==$payload,409,'Dados do cadastro já existente diferem; preservar a versão anterior.'); 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']]); 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.'];
return ['id' => $r->id, 'organization' => $known, 'message' => 'Organização existente recuperada.'];
} }
$id=$r->id; $id = $r->id;
$before=['name'=>$r->name,'external_id'=>$r->external_id,'source_system'=>$r->source_system,'version_no'=>$r->version_no]; $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.'); 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]); 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. // Reconcile only the explicit comma alias; preserve previous values in audit.
}else{ } else {
$id=(string)Str::uuid(); $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']); 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, 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]); '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.'];
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<array-key, mixed> $input
*/
public function grant(User $actor, array $input): string
{ {
app(PortalProfileAdministration::class)->administrative($actor); 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'])], $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', '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)], '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', 'confirmed_internal_designation' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'responsibilities'=>'sometimes|array|max:0','expires_at'=>'nullable|date|after:now'])->validate(); '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.'); abort_if((int) $v['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.');
$subject=User::findOrFail($v['user_id']); $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.'); 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']); $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.'); 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.'); if ($v['role'] === 'promoter') {
return DB::transaction(function()use($actor,$v){ 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)'); DB::select('SELECT pg_advisory_xact_lock(101026,40002)');
$subject=User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail(); $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.'); 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(); $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'])); $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'];} if ($e) {
$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(); $d = json_decode($e->after_data, true);
abort_if($duplicate,409,'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.'); abort_unless($d['designation'] == $payload, 409, 'Chave usada numa atribuição diferente.');
$id=(string)Str::uuid(); abort_unless(DB::table('portal_role_assignments')->where('id', $d['assignment_id'])->whereNull('revoked_at')->exists(), 409, 'A atribuição foi revogada.');
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'], return $d['assignment_id'];
'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]); $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; return $id;
}); });
} }

Some files were not shown because too many files have changed in this diff Show More