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\Models\User;
use App\Support\CheckedFile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -53,7 +54,9 @@ class ImportDocumentBatch extends Command
];
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
private const TRIO_HASHES = [
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
@@ -276,7 +279,9 @@ class ImportDocumentBatch extends Command
return $failures > 0 ? self::FAILURE : self::SUCCESS;
}
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate.
* @param array<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
{
$allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES);
@@ -310,7 +315,7 @@ class ImportDocumentBatch extends Command
->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true)
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']);
if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key)
|| ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, hash_file('sha256', Storage::disk('local')->path($images[0]->storage_key)))) {
|| ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, CheckedFile::sha256(Storage::disk('local')->path($images[0]->storage_key)))) {
throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.');
}
}
@@ -344,8 +349,8 @@ class ImportDocumentBatch extends Command
|| ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|| $verified === false || $verified > time() || $verified < time() - 86400
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|| ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
|| ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))) {
|| ! hash_equals($proof['sha256'] ?? '', CheckedFile::sha256($proof['dump_path']))
|| ! hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))) {
throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.');
}
}
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command;
@@ -24,8 +25,8 @@ class RegisterFiscalQaDraft extends Command
$source = InvestmentLegalSource::findOrFail($this->argument('source'));
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022'
&& $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).');
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
$payload = json_decode(CheckedFile::read(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$actualHash = CheckedFile::sha256(base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.');
$payload['legal_source_id'] = (string) $source->id;
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
@@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -21,7 +21,7 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$sectors = \App\Services\SectorOrganization::catalog();
$sectors = SectorOrganization::catalog();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
@@ -8,11 +8,13 @@ use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived;
use App\Models\ExistingBusiness;
use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator;
@@ -26,7 +28,7 @@ class InterestRequestController extends Controller
$validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key), 422, 'Identificador do envio em falta.');
Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
Validator::make(['idempotency_key' => $key], ['idempotency_key' => ['required', 'uuid']])->validate();
try {
$interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) {
@@ -78,19 +80,20 @@ class InterestRequestController extends Controller
}
}
$row = \App\Models\InvestmentSubmission::create([
$row = InvestmentSubmission::create([
'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1,
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity->title ?? $business?->public_title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
'country' => $validated['country'], 'existing_business_id' => $business?->id,
'modality' => $validated['modality'] ?? null],
]);
\Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([
DB::table('investment_submission_events')->insert([
'submission_id' => $row->id, 'actor_id' => $request->user()->id,
'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest;
});
@@ -99,7 +102,8 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
'status' => $interestRequest->status,
// The POST acknowledgement is stable on replay; current workflow state is in tracking.
'status' => InterestStatus::Received,
'tracking_url' => '/investidor/manifestacoes',
],
], 201);
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\EconomicReferenceCatalog;
use App\Services\SectorOrganization;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -44,7 +47,9 @@ class ReferenceCatalogController extends Controller
$cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae';
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ');
if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
if ($cae && ! app(EconomicReferenceCatalog::class)->available()) {
$versionQuery->whereRaw('false');
}
if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else {
@@ -82,7 +87,7 @@ class ReferenceCatalogController extends Controller
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
if ($cae) {
$paths = app(\App\Services\EconomicReferenceCatalog::class)->activities()->keyBy('id');
$paths = app(EconomicReferenceCatalog::class)->activities()->keyBy('id');
$items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']);
}
@@ -169,15 +174,17 @@ class ReferenceCatalogController extends Controller
->where('valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID);
if (empty($data['version'])) {
$query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID);
}
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']);
} else {
$query->whereNull('parent_id');
}
if (! empty($data['sector_id'])) {
$sector = \App\Models\Sector::find($data['sector_id']);
$query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::whereKey($data['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
@@ -4,13 +4,14 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
class SectorController extends Controller
{
public function index(): JsonResponse
{
$sectors = \App\Services\SectorOrganization::catalog()->map(fn ($s) => [
$sectors = SectorOrganization::catalog()->map(fn ($s) => [
'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug,
'shortDescription' => $s->short_description, 'description' => $s->description,
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
@@ -25,7 +26,8 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail();
$sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => \App\Services\SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
$sector->setRelation('opportunities', SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
}
}
@@ -5,14 +5,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SimulationOfferDocumentController extends Controller
{
public function store(Request $request, string $id, SimulationFormDrafts $drafts)
public function store(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
@@ -20,6 +23,7 @@ class SimulationOfferDocumentController extends Controller
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath());
abort_unless(is_string($hash), 422, 'Não foi possível verificar o conteúdo do anexo.');
$existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store');
@@ -37,32 +41,37 @@ class SimulationOfferDocumentController extends Controller
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store');
}
public function index(Request $request, string $id, SimulationFormDrafts $drafts)
public function index(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
$documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get();
return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store');
}
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts)
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts): BinaryFileResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$drafts->find($id, $request);
$doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first();
abort_unless($doc, 404);
abort_unless($doc !== null, 404);
abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.');
$root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
abort_unless(hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 409, 'Conteúdo do anexo diverge do hash registado.');
$hash = hash_file('sha256', $path);
abort_unless(is_string($hash) && hash_equals(trim($doc->sha256), $hash), 409, 'Conteúdo do anexo diverge do hash registado.');
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function metadata(string $id, object $doc): array
/** @return array{id: string, draftId: string, name: string, sha256: string, scan: string, url: string} */
private function metadata(string $id, stdClass $doc): array
{
return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256),
'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id];
@@ -7,9 +7,11 @@ use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
final class CorridorAgribusinessController extends Controller
{
@@ -18,31 +20,36 @@ final class CorridorAgribusinessController extends Controller
return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR));
}
public function show(Request $request, CorridorAgribusinessOpportunities $service)
public function show(Request $request, CorridorAgribusinessOpportunities $service): Response
{
$access = app(PortalProfileAccess::class);
abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403);
$records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key');
foreach ($records as $record) abort_unless($access->canView($request->user(), $record), 403);
foreach ($records as $record) {
abort_unless($access->canView($request->user(), $record), 403);
}
return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
'entries' => array_map(function ($entry) use ($records) {
$r = $records->get($entry['key']);
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
}, $service->entries())]);
}
public function register(Request $request, CorridorAgribusinessOpportunities $service)
public function register(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse
{
$data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.');
$access = app(PortalProfileAccess::class);
$grant = $access->internalAssignment($request->user(), 'investment_project');
abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.');
return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]);
}
public function preview(Request $request, CorridorAgribusinessOpportunities $service)
public function preview(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse
{
$previews = [];
foreach ($service->entries() as $entry) {
@@ -52,21 +59,27 @@ final class CorridorAgribusinessController extends Controller
$previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record,
[...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])];
}
return response()->json(['previews' => $previews]);
}
public function disclose(Request $request, CorridorAgribusinessOpportunities $service)
public function disclose(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse
{
$data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct',
'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']);
$expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all();
$supplied = array_column($data['previews'], 'id'); sort($supplied);
$supplied = array_column($data['previews'], 'id');
sort($supplied);
abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.');
return response()->json(DB::transaction(function () use ($request, $data) {
$result = [];
foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
foreach ($data['previews'] as $preview) {
$result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
}
return ['records' => $result];
}));
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Services\DocumentManagementAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -12,7 +13,7 @@ class DocumentMalwareController extends Controller
public function retry(Request $request, string $id): RedirectResponse
{
DB::transaction(function () use ($id, $request): void {
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
$document = app(DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
@@ -2,63 +2,79 @@
namespace App\Http\Controllers;
use App\Services\VerifiedDocumentBinary;
use App\Services\DocumentManagementAccess;
use App\Services\EditorialInstallation;
use App\Services\EditorialReading;
use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;
class EditorialArticleController extends Controller
{
public function installation(Request $request, \App\Services\EditorialInstallation $installation)
public function installation(Request $request, EditorialInstallation $installation): Response
{
$plan = $installation->preview($request->user());
return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store')
: response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store');
: response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store');
}
public function install(Request $request, \App\Services\EditorialInstallation $installation)
public function install(Request $request, EditorialInstallation $installation): Response
{
$receipt = $installation->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store')
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
}
public function showMfa(Request $request)
public function showMfa(Request $request): Response
{
return $this->confirmMfa($request);
}
public function confirmMfa(Request $request)
public function confirmMfa(Request $request): Response
{
$actor = $request->user();
abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.');
if (app(\App\Services\PortalProfileAccess::class)->mfa($actor)) {
if (app(PortalProfileAccess::class)->mfa($actor)) {
return redirect()->route('editorial.installation');
}
if ($request->isMethod('get')) {
return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store');
}
abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']), 422, 'A conta e o destino são obtidos da sessão e do servidor.');
abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']) !== [], 422, 'A conta e o destino são obtidos da sessão e do servidor.');
$data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']);
try {
$recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code']));
$valid = $recovery !== null || (!empty($data['code']) && app(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify(
\Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
$valid = $recovery !== null || (! empty($data['code']) && app(TwoFactorAuthenticationProvider::class)->verify(
Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
));
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
} catch (DecryptException $e) {
$valid = false;
}
if (!$valid) {
event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor));
if (! $valid) {
event(new TwoFactorAuthenticationFailed($actor));
throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']);
}
if ($recovery !== null) {
$actor->replaceRecoveryCode($recovery);
}
event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor));
event(new ValidTwoFactorAuthenticationCodeProvided($actor));
$request->session()->regenerate();
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store');
}
@@ -67,7 +83,7 @@ class EditorialArticleController extends Controller
abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.');
}
private function publicResources()
private function publicResources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
@@ -75,28 +91,29 @@ class EditorialArticleController extends Controller
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
}
public function edit(int $id)
public function edit(int $id): Response
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->find($id);
abort_unless($r, 404);
$reading = app(\App\Services\EditorialReading::class);
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r instanceof \stdClass, 404);
$reading = app(EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request())->header('Cache-Control', 'private, no-store');
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function save(Request $request, int $id)
public function save(Request $request, int $id): RedirectResponse
{
$this->installed();
$data = $this->validateContent($request);
DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless($r !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
@@ -108,7 +125,7 @@ class EditorialArticleController extends Controller
$content = $this->edition($r, $data);
$revision = (int) $r->metadata_version + 1;
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
$resource = DB::table('portal_resources')->find($id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => 'metadata_updated', 'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => $request->user()->id, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'portal-management', 'actor_user_id' => $request->user()->id, 'entity_type' => 'portal_resources', 'entity_id' => null, 'action' => 'editorial_updated', 'before_data' => json_encode(['record_id' => $id, 'metadata_version' => $r->metadata_version]), 'after_data' => json_encode(['record_id' => $id, 'metadata_version' => $revision, 'published' => false])]);
@@ -117,46 +134,64 @@ class EditorialArticleController extends Controller
return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.');
}
/**
* @return array<array-key, mixed>
*/
private function validateContent(Request $request): array
{
return $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'image_mode' => ['nullable', 'in:original,selected,none'], 'metadata_version' => ['required', 'integer', 'min:1']]);
}
private function edition(object $resource, array $data): array
/**
* @param array<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);
$mode = $data['image_mode'] ?? (!empty($data['image_resource_id']) ? 'selected' : 'none');
if ($mode === 'selected') abort_unless(!empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int)$data['image_resource_id'] : null,
$mode = $data['image_mode'] ?? (! empty($data['image_resource_id']) ? 'selected' : 'none');
if ($mode === 'selected') {
abort_unless(! empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
}
return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int) $data['image_resource_id'] : null,
'preserve_original_html' => $original && trim($data['body']) === $reading->plain($original['content_html'])];
}
public function preview(Request $request, int $id)
public function preview(Request $request, int $id): Response
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)])
->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)])
->toResponse($request), static function (Response $response): void {
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
});
}
public function previewContent(Request $request, int $id)
public function previewContent(Request $request, int $id): JsonResponse
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$data = $this->validateContent($request);
abort_unless((int)$resource->metadata_version === (int)$data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
return response()->json(['article' => app(\App\Services\EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)])
abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
return response()->json(['article' => app(EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)])
->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
}
public function show(Request $request, int $id)
public function show(Request $request, int $id): Response
{
$this->installed();
$resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store');
$resource = app(EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource !== null, 404);
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource)])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
}
@@ -11,6 +11,7 @@ use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\ExistingBusinessVerification;
use App\Services\InvestmentGeography;
use App\Services\Mne\BusinessRepository;
@@ -67,7 +68,7 @@ class ExistingBusinessController extends Controller
$business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business);
app(CompatibilityShadow::class)->schedule($id);
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $business),
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
@@ -3,25 +3,32 @@
namespace App\Http\Controllers;
use App\Services\InvestmentComplements;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class InvestmentComplementController extends Controller
{
public function index(Request $request, InvestmentComplements $service)
public function index(Request $request, InvestmentComplements $service): JsonResponse
{
$data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
$data = $request->validate(['origin' => [Rule::requiredIf(! $request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
$items = [];
$issues = [];
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
try {
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404;
if (!isset($data['contexts']) || $status !== 404) throw $e;
} catch (ModelNotFoundException|HttpException $e) {
$status = $e instanceof HttpException ? $e->getStatusCode() : 404;
if (! isset($data['contexts']) || $status !== 404) {
throw $e;
}
$issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.'];
}
}
return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store');
}
}
@@ -4,9 +4,14 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\OpportunityCorridorExplorer;
use App\Services\SectorInvestment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -26,9 +31,9 @@ class InvestmentDiscoveryController extends Controller
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(OpportunityCorridorExplorer::class)->groups($filters) : [],
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'guides' => app(SectorInvestment::class)->guides(Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -89,7 +94,7 @@ class InvestmentDiscoveryController extends Controller
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
$linked = in_array($record->id, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true;
@@ -107,8 +112,8 @@ class InvestmentDiscoveryController extends Controller
$returnFilters = $request->query();
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
return Inertia::render('investment/detail', ['complements' => app(InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
@@ -6,6 +6,9 @@ use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
@@ -112,7 +115,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
$query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
$query->orWhereIn('id', app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
@@ -138,9 +141,9 @@ class InvestmentPortfolioController extends Controller
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(\App\Services\ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
@@ -149,6 +152,7 @@ class InvestmentPortfolioController extends Controller
{
self::visible()->findOrFail($id);
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
}
@@ -8,6 +8,7 @@ use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
@@ -50,7 +51,7 @@ class InvestmentStructureController extends Controller
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
->orderBy('origins.code')->orderBy('destinations.code')
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
'competitiveness' => $structure === 'clusters' && $id ? app(\App\Services\ClusterCompetitiveness::class)->management($id) : null,
'competitiveness' => $structure === 'clusters' && $id ? app(ClusterCompetitiveness::class)->management($id) : null,
'viewerId' => $request->user()->id,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
@@ -10,6 +10,7 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers;
@@ -151,7 +152,7 @@ class InvestmentSubmissionController extends Controller
public function bindContext(Request $request, string $id): JsonResponse
{
abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.');
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.');
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']) !== [], 422, 'Parâmetros de associação inválidos.');
$data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data) {
// Serialize all links for this submission before locking the selected case.
@@ -250,8 +251,12 @@ class InvestmentSubmissionController extends Controller
}
$bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
if ($bound) $data['payload'] = app(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
if ($bound) {
$data['payload'] = app(InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
} else {
unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
}
return $data;
}
@@ -271,7 +276,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id' => (int) substr($data['payload']['context_opportunity'], 12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -316,7 +321,7 @@ class InvestmentSubmissionController extends Controller
}
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$scopeKey = $data['payload']['context_opportunity'] ?? null;
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey, 12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
@@ -344,7 +349,7 @@ class InvestmentSubmissionController extends Controller
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
$row->update(['opportunity_id' => $opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -5,14 +5,17 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\EconomicReferenceCatalog;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentTerritorialTraceability;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalDetailsInstallation;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -33,7 +36,7 @@ class InvestmentZoneController extends Controller
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
$zone->setAttribute('territory_path', $territoryPath);
$zone->setAttribute('operational_details_public',app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone));
$zone->setAttribute('operational_details_public', app(InvestmentZoneOperationalDetails::class)->publicData($zone));
$publicFields = ['operational_details_public', 'version_no', 'published_at', 'id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at'];
$locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
@@ -68,7 +71,7 @@ class InvestmentZoneController extends Controller
});
$props = ['record' => $management ? app(InvestmentZoneOperationalProfile::class)->management($zone) : app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->only($publicFields)), 'source' => $source, 'operator' => $operator, 'management' => $management,
'profileAvailable' => $management && app(InvestmentZoneOperationalProfile::class)->installed(),
'zoneContext' => !$management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id,(int)$zone->version_no) : null,
'zoneContext' => ! $management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id, (int) $zone->version_no) : null,
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
'related' => $related->unique('id')->values(),
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
@@ -77,7 +80,7 @@ class InvestmentZoneController extends Controller
'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null,
'opportunities' => $opportunities,
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [],
'economicActivities' => $management ? app(\App\Services\EconomicReferenceCatalog::class)->activities() : [],
'economicActivities' => $management ? app(EconomicReferenceCatalog::class)->activities() : [],
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
'locations' => $locations,
@@ -99,7 +102,9 @@ class InvestmentZoneController extends Controller
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$path = app(InvestmentGeography::class)->selection($filters);
$publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';}
if (app(InvestmentZoneOperationalDetails::class)->installed()) {
$publicColumns[] = 'operational_details';
}
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
@@ -113,11 +118,13 @@ class InvestmentZoneController extends Controller
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
if (! $management) { $zone->makeHidden('primary_territory_id'); }
if (! $management) {
$zone->makeHidden('primary_territory_id');
}
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)];
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()), 'operational_details_public' => app(InvestmentZoneOperationalDetails::class)->publicData($zone)];
});
$props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
$props = ['detailsInstallation' => $management ? app(InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
@@ -193,35 +200,35 @@ class InvestmentZoneController extends Controller
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'],
'operational_details' => ['sometimes','array'],
'operational_details' => ['sometimes', 'array'],
'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
'activities.*.id' => ['nullable','uuid'],
'activities.*.source_date' => ['nullable','date_format:Y-m-d'],
'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable','in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable','string','max:120'],
'activities.*.capacity_value' => ['nullable','numeric','min:0'],
'activities.*.capacity_unit' => ['nullable','string','max:40'],
'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes','array','max:10'],
'activities.*.id' => ['nullable', 'uuid'],
'activities.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'activities.*.kind' => ['nullable', 'in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable', 'in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable', 'string', 'max:120'],
'activities.*.capacity_value' => ['nullable', 'numeric', 'min:0'],
'activities.*.capacity_unit' => ['nullable', 'string', 'max:40'],
'activities.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes', 'array', 'max:10'],
'activities.*.support_document_ids.*' => ['uuid'],
'activities.*.private_note' => ['nullable','string','max:2000'],
'activities.*.private_note' => ['nullable', 'string', 'max:2000'],
'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'],
'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
'resources.*.id' => ['nullable','uuid'],
'resources.*.source_date' => ['nullable','date_format:Y-m-d'],
'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable','in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable','string','max:120'],
'resources.*.capacity_value' => ['nullable','numeric','min:0'],
'resources.*.capacity_unit' => ['nullable','string','max:40'],
'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes','array','max:10'],
'resources.*.id' => ['nullable', 'uuid'],
'resources.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'resources.*.kind' => ['nullable', 'in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable', 'in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable', 'string', 'max:120'],
'resources.*.capacity_value' => ['nullable', 'numeric', 'min:0'],
'resources.*.capacity_unit' => ['nullable', 'string', 'max:40'],
'resources.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes', 'array', 'max:10'],
'resources.*.support_document_ids.*' => ['uuid'],
'resources.*.private_note' => ['nullable','string','max:2000'],
'resources.*.private_note' => ['nullable', 'string', 'max:2000'],
'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'],
'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'],
'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'],
@@ -257,17 +264,26 @@ class InvestmentZoneController extends Controller
$data['district'] = $path[1]->name ?? null;
}
$saved=$zones->save($id, $data, $request->user()->id);
$saved = $zones->save($id, $data, $request->user()->id);
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201);
}
public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version'=>['required_if:action,reconcile','nullable','integer','min:1']]);
if($action==='reconcile'){
$request->validate(['base_public_version'=>'required|integer|min:1']);
$saved=DB::transaction(function()use($id,$data,$request){$zone=InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();abort_unless($zone->category==='industrial_park',422);return app(InvestmentZoneOperationalProfile::class)->reconcile($zone,(int)$data['version_no'],(int)$data['base_public_version'],$request->user()->id);});
}else{$saved=$action === 'publish' ? $zones->publish($id,$data['version_no'],$request->user()->id) : $zones->suspend($id,$data['version_no'],$request->user()->id);}
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version' => ['required_if:action,reconcile', 'nullable', 'integer', 'min:1']]);
if ($action === 'reconcile') {
$request->validate(['base_public_version' => 'required|integer|min:1']);
$saved = DB::transaction(function () use ($id, $data, $request) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
abort_unless($zone->category === 'industrial_park', 422);
return app(InvestmentZoneOperationalProfile::class)->reconcile($zone, (int) $data['version_no'], (int) $data['base_public_version'], $request->user()->id);
});
} else {
$saved = $action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id) : $zones->suspend($id, $data['version_no'], $request->user()->id);
}
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
}
@@ -2,82 +2,106 @@
namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Services\InvestmentComplements;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityChoices;
use App\Services\InvestorContext;
use App\Services\InvestorContextInstallation;
use App\Services\InvestorPath;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class InvestorContextController extends Controller
{
/**
* @return array<array-key, mixed>
*/
private function entryComplements(Request $request): array
{
$data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40',
'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']);
if (empty($data['complement_origin'])) return [];
return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
if (empty($data['complement_origin'])) {
return [];
}
return app(InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
}
/**
* @return array<array-key, mixed>
*/
private function entryChoices(Request $r, InvestorContext $context): array
{
$data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1',
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
$source = !empty($data['source']) ? $context->reference($data['source']) : null;
$source = ! empty($data['source']) ? $context->reference($data['source']) : null;
$ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []);
abort_if(!empty($data['from_dossier']) && !$ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
abort_if(! empty($data['from_dossier']) && ! $ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
$path = app(InvestmentGeography::class)->selection($data);
$input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']];
$input = array_map(fn ($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
if (! empty($data['from_dossier'])) {
$input[0]['portfolio_origins'] = [$data['from_dossier']];
}
foreach ($this->entryComplements($r) as $complement) {
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn($key) => str_starts_with($key, 'opportunity:'));
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn ($key) => str_starts_with($key, 'opportunity:'));
foreach ($keys as $key) {
if (in_array($key, array_column($input, 'source'), true)) continue;
if (in_array($key, array_column($input, 'source'), true)) {
continue;
}
$input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []),
...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])];
}
}
return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
return app(InvestmentOpportunityChoices::class)->resolve($input, [], true);
}
public function current(Request $r, InvestorContext $context)
public function current(Request $r, InvestorContext $context): JsonResponse
{
return response()->json($context->current($r->user()));
}
public function focus(Request $r, InvestorContext $context)
public function focus(Request $r, InvestorContext $context): JsonResponse
{
return response()->json($context->focus($r->user(), $r->all()));
}
public function show(Request $r, string $id, InvestorContext $context)
public function show(Request $r, string $id, InvestorContext $context): JsonResponse
{
$data = $r->validate(['opportunity' => 'nullable|string|max:100']);
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
}
public function options(Request $r, InvestorContext $context)
public function options(Request $r, InvestorContext $context): JsonResponse
{
$d = $r->validate(['q' => 'nullable|string|max:120']);
return response()->json($context->options($d['q'] ?? ''));
}
public function corridorOptions(Request $r)
public function corridorOptions(Request $r): JsonResponse
{
$data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(\App\Services\InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(),
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values(),
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
}
public function saveDraft(Request $r, string $id, InvestorContext $context)
public function saveDraft(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
}
public function edit(Request $r, string $id, InvestorContext $context)
public function edit(Request $r, string $id, InvestorContext $context): Response
{
$data = $context->view($r->user(), $id);
abort_unless($data['can_write'], 403);
@@ -85,72 +109,84 @@ final class InvestorContextController extends Controller
$entries = $this->entryChoices($r, $context);
$choices = $entries ?: null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
$explicitChoices=$choices;
$selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null;
$entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:'');
$entrySources=array_map($entryKey,$explicitChoices??[]);
if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') {
$choices = app(InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
}
$explicitChoices = $choices;
$selectionKey = $r->validate(['selection_key' => 'nullable|uuid'])['selection_key'] ?? null;
$entryKey = fn ($c) => $c['opportunity']['key'].($selectionKey ? '@'.$selectionKey : '');
$entrySources = array_map($entryKey, $explicitChoices ?? []);
if ($choices !== null) {
$choices = app(InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
}
$draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
$new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true));
$choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
$recovered = app(InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
$new = array_filter($explicitChoices ?? [], fn ($c) => ! in_array($entryKey($c), $draft['entry_sources'] ?? [], true));
$choices = app(InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
}
$differences = [];
foreach ($explicitChoices ?? [] as $entry) {
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) {
$differences[] = ['title' => $entry['opportunity']['title'], 'saved' => $kept['territory']['title'] ?? 'Por definir', 'requested' => $entry['territory']['title'] ?? 'Por definir'];
}
}
$complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') {
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
if ($path) $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
if ($path) {
$entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
}
}
foreach ($this->entryComplements($r) as $entry) {
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
$entrySources[] = $tag;
if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all();
if (! in_array($tag, $draft['entry_sources'] ?? [], true)) {
$complements = collect([...$complements, $entry])->unique('key')->values()->all();
}
}
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
}
public function preview(Request $r, string $id, InvestorContext $context)
public function preview(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($context->preview($r->user(), $id, $r->all()));
}
public function commit(Request $r, string $id, InvestorContext $context)
public function commit(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($context->commit($r->user(), $id, $r->all()));
}
public function start(Request $r, InvestorPath $path, InvestorContext $context)
public function start(Request $r, InvestorPath $path, InvestorContext $context): Response
{
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
$entrySubmission = null;
if ($submissionId) {
$submission = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
$submission = InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.');
$entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
}
$choices = $this->entryChoices($r, $context);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install)
public function installation(Request $r, InvestorContextInstallation $install): JsonResponse|Response
{
$plan = $install->preview($r->user());
return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]);
}
public function install(Request $r, InvestorContextInstallation $install)
public function install(Request $r, InvestorContextInstallation $install): JsonResponse
{
return response()->json($install->apply($r->user(),$r->all()));
return response()->json($install->apply($r->user(), $r->all()));
}
}
+21 -12
View File
@@ -3,8 +3,8 @@
namespace App\Http\Controllers;
use App\Services\FintechInvestment;
use App\Services\InvestorPath;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\SectorInvestment;
use Illuminate\Http\JsonResponse;
@@ -39,24 +39,29 @@ class InvestorPathController extends Controller
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Setor associado ao mesmo dossier.');
}
public function zoneStart(Request $request,string $zoneId,InvestorPath $path): JsonResponse|Response
public function zoneStart(Request $request, string $zoneId, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(),404);
$data=$request->validate(['consulted_public_version'=>'required|integer|min:1']);
$data=['contexts'=>$path->contexts($request->user()),'zoneSource'=>app(InvestmentZonePathContext::class)->context($zoneId,(int)$data['consulted_public_version'])];
return $request->expectsJson()?response()->json($data):Inertia::render('investor-path/index',$data);
abort_unless($path->available(), 404);
$data = $request->validate(['consulted_public_version' => 'required|integer|min:1']);
$data = ['contexts' => $path->contexts($request->user()), 'zoneSource' => app(InvestmentZonePathContext::class)->context($zoneId, (int) $data['consulted_public_version'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
public function zoneAttach(Request $request,string $id): JsonResponse|RedirectResponse
public function zoneAttach(Request $request, string $id): JsonResponse|RedirectResponse
{
app(InvestmentZonePathContext::class)->attach($request->user(),$id,$request->all());
return $request->expectsJson()?response()->json(['ok'=>true]):redirect('/investimentos/'.$id)->with('success','Referência do parque guardada; não constitui reserva ou autorização.');
app(InvestmentZonePathContext::class)->attach($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência do parque guardada; não constitui reserva ou autorização.');
}
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{
$zone=$request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
if($zone){ $request->validate(['confirm'=>'required|accepted']); app(InvestmentZonePathContext::class)->context($zone['zone_id'],(int)$zone['consulted_public_version']); }
$zone = $request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
if ($zone) {
$request->validate(['confirm' => 'required|accepted']);
app(InvestmentZonePathContext::class)->context($zone['zone_id'], (int) $zone['consulted_public_version']);
}
$sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null;
if ($sector !== null) {
app(SectorInvestment::class)->context($sector);
@@ -79,7 +84,11 @@ class InvestorPathController extends Controller
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector]);
}
if($zone){$case=$path->view($request->user(),$id)['case']; app(InvestmentZonePathContext::class)->attach($request->user(),$id,[...$zone,'version'=>$case['version'],'confirm'=>true]);}
if ($zone) {
$case = $path->view($request->user(), $id)['case'];
app(InvestmentZonePathContext::class)->attach($request->user(), $id, [...$zone, 'version' => $case['version'], 'confirm' => true]);
}
return $id;
});
@@ -377,6 +377,9 @@ class LandAssistanceController extends Controller
'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)];
}
/**
* @return array<array-key, mixed>|null
*/
private function dossierContext(LandAssistanceCase $case): ?array
{
$id = $case->payload['investor_path_case_id'] ?? null;
@@ -12,9 +12,12 @@ use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityPromotion;
use App\Services\OpportunityResources;
use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess;
use App\Services\ProactiveInvestment;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -76,8 +79,8 @@ class OpportunityManagementController extends Controller
$query->where('opportunity_category', $filters['category']);
}
if ($filters['sector_id'] ?? null) {
$sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::whereKey($filters['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if ($filters['geography_scope'] ?? null) {
$query->where('geography_scope', $filters['geography_scope']);
@@ -103,7 +106,7 @@ class OpportunityManagementController extends Controller
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
$r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r));
$r->setAttribute('can_promote', app(OpportunityPromotion::class)->allowed($request->user(), $r));
$r->setAttribute('working_status', $access->state($r));
$r->setAttribute('publication', $r->publicationPresentation());
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
@@ -131,7 +134,7 @@ class OpportunityManagementController extends Controller
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
'publicationState' => $record?->publicationPresentation(),
'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
'promotionHref' => $record && app(OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
@@ -147,7 +150,7 @@ class OpportunityManagementController extends Controller
Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? []));
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(\App\Services\ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(\App\Services\ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES,
return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES,
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS],
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
@@ -4,22 +4,24 @@ namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityPromotion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class OpportunityPromotionController extends Controller
{
public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
public function show(Request $request, int $id, OpportunityPromotion $service): Response
{
return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id)));
}
public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
public function preview(Request $request, int $id, OpportunityPromotion $service): JsonResponse
{
return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
}
public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
public function confirm(Request $request, int $id, OpportunityPromotion $service): JsonResponse
{
return response()->json($service->confirm($request->user(), $id, $request->all()));
}
@@ -6,9 +6,11 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InvestmentGeography;
use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityLibrarySupport;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use App\Services\OpportunityTerritorialPresentation;
use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess;
use App\Services\VerifiedDocumentBinary;
@@ -98,12 +100,13 @@ class OpportunityResourceController extends Controller
}
}
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
}
public function reuseLibrary(Request $request, int $id): JsonResponse
{
$row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all());
$row = app(OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all());
return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']);
}
@@ -150,15 +153,18 @@ class OpportunityResourceController extends Controller
{
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id);
app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
abort_if($opportunity->status === 'archived', 404);
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
$support = DB::table($service->table($kind))->where('id', $resourceId)->first();
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record);
if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
app(OpportunityLibrarySupport::class)->assertAvailable($support, $record);
if (request()->filled('source_sha256')) {
abort_unless(hash_equals(trim($record->sha256), (string) request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
}
return app(VerifiedDocumentBinary::class)->respond($record);
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
@@ -11,6 +12,7 @@ use App\Services\InvestmentRelations;
use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching;
use App\Services\PartnershipSources;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -45,9 +47,11 @@ class PartnershipExchangeController extends Controller
foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) {
if ($field === 'sector_id') {
$sector = \App\Models\Sector::find($filters[$field]);
$query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else { $query->where('p.'.$field, $filters[$field]); }
$sector = Sector::whereKey($filters[$field])->first();
$query->whereIn('p.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else {
$query->where('p.'.$field, $filters[$field]);
}
}
}
if (! empty($filters['q'])) {
@@ -393,7 +397,7 @@ class PartnershipExchangeController extends Controller
}
}
return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => \App\Models\Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']),
'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []];
@@ -15,6 +15,8 @@ use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -55,32 +57,38 @@ class PortalManagementController extends Controller
private function interestAssignmentEtag(InterestRequest $interest): string
{
return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR));
return hash('sha256', json_encode([$interest->id, $interest->status, $interest->assigned_to, $interest->updated_at?->format('Y-m-d H:i:s.u')], JSON_THROW_ON_ERROR));
}
public function interestAssignments(Request $request): Response|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at'])
->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]);
$data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
$rows = InterestRequest::whereNotIn('status', ['cancelled', 'closed', 'refused', 'converted'])->orderBy('id')->get(['id', 'reference', 'status', 'assigned_to', 'updated_at'])
->map(fn ($row) => ['id' => $row->id, 'protocol' => $row->reference, 'status' => $row->status, 'analyst_id' => $row->assigned_to, 'version' => 0, 'etag' => $this->interestAssignmentEtag($row), 'due_at' => null]);
$data = ['kind' => 'interest', 'requests' => $rows, 'analysts' => $this->interestAssignees()->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
}
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']);
DB::transaction(function()use($request,$interest,$data){
$row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']);
if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
$owner=(int)($row->metadata['portal_user_id']??0);
abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.');
PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id);
app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]);
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
$data = $request->validate(['etag' => 'required|string|size:64', 'analyst_id' => ['required', 'integer', Rule::in($this->interestAssignees()->pluck('id'))], 'reason' => 'required|string|min:10|max:2000'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 10 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.in' => 'Seleccione um responsável autorizado.']);
DB::transaction(function () use ($request, $interest, $data) {
$row = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (! hash_equals($this->interestAssignmentEtag($row), $data['etag'])) {
throw ValidationException::withMessages(['etag' => 'A atribuição mudou. Recupere a lista actual e tente novamente.']);
}
if (in_array($row->status, ['cancelled', 'closed', 'refused', 'converted'], true)) {
throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
}
$owner = (int) ($row->metadata['portal_user_id'] ?? 0);
abort_if($owner === (int) $data['analyst_id'] || InvestmentSubmission::where('interest_request_id', $row->id)->where('user_id', $data['analyst_id'])->exists(), 422, 'O interessado não pode acompanhar o seu próprio pedido como equipa.');
PortalAudit::save($row, ['assigned_to' => (int) $data['analyst_id']], $request->user()->id);
app(PortalProfileAccess::class)->audit($request->user(), 'interest_assigned', ['interest_id' => $row->id, 'assigned_to' => (int) $data['analyst_id'], 'reason' => $data['reason']]);
});
return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.');
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Responsável atribuído.');
}
public function interests(Request $request): Response
@@ -95,7 +103,7 @@ class PortalManagementController extends Controller
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user());
app(ScopedPortalAdministration::class)->interestQuery($query, $request->user());
if ($country = $filters['country'] ?? null) {
$values = app(CountryCatalogue::class)->filterValues($country);
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
@@ -142,7 +150,7 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403);
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) {
@@ -170,7 +178,7 @@ class PortalManagementController extends Controller
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403);
$validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
@@ -198,7 +206,7 @@ class PortalManagementController extends Controller
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
@@ -208,7 +216,7 @@ class PortalManagementController extends Controller
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
@@ -301,8 +309,8 @@ class PortalManagementController extends Controller
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::whereKey($filters['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
return Inertia::render('management/catalog', [
@@ -319,7 +327,7 @@ class PortalManagementController extends Controller
public function edit(string $resource, ?int $id = null): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : null;
$record = $id ? $model::whereKey($id)->firstOrFail() : null;
return Inertia::render('management/editor', [
'resource' => $resource,
@@ -335,7 +343,7 @@ class PortalManagementController extends Controller
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
}
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : new $model;
$record = $id ? $model::whereKey($id)->firstOrFail() : new $model;
$rules = $resource === 'sectors' ? [
'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
@@ -51,6 +52,10 @@ class PortalProfileController extends Controller
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
'staffAccounts' => User::query()->orderBy('name')->get(['id', 'name', 'email', 'email_verified_at', 'two_factor_confirmed_at'])->map(fn (User $user) => [
'id' => $user->id, 'name' => $user->name, 'email' => $user->email,
'verified' => $user->email_verified_at !== null, 'mfa' => $user->two_factor_confirmed_at !== null,
]),
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
@@ -30,6 +30,7 @@ class PortalProfileInstallationController extends Controller
public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response
{
$plan = $installation->bootstrapPreview($request->user(), $id);
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
}
@@ -4,13 +4,20 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\DocumentManagementAccess;
use App\Services\EconomicReferenceCatalog;
use App\Services\EditorialReading;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent;
use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder;
@@ -18,6 +25,7 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
@@ -26,6 +34,7 @@ use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PortalServicesController extends Controller
{
@@ -70,7 +79,7 @@ class PortalServicesController extends Controller
]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') {
$props['sectors'] = \App\Services\SectorOrganization::catalog();
$props['sectors'] = SectorOrganization::catalog();
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
@@ -116,7 +125,7 @@ class PortalServicesController extends Controller
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
$pageResult->getCollection()->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($resource));
$props['resources'] = $pageResult;
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
@@ -188,7 +197,7 @@ class PortalServicesController extends Controller
}
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']);
$resources->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
$resources->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($resource));
return response()->json(['data' => $resources]);
}
@@ -266,7 +275,7 @@ class PortalServicesController extends Controller
abort_unless($resource !== null, 404);
$resource->territory_name = $resource->territory_id
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null;
$resource = app(\App\Services\PublicDocumentPresentation::class)->resource($resource);
$resource = app(PublicDocumentPresentation::class)->resource($resource);
$back = $request->query('back');
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
? $back : '/documentos';
@@ -289,7 +298,7 @@ class PortalServicesController extends Controller
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
$document = app(DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
@@ -299,7 +308,7 @@ class PortalServicesController extends Controller
public function manage(Request $request, MalwareScanner $scanner): Response
{
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
$access = app(\App\Services\DocumentManagementAccess::class);
$access = app(DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health();
@@ -314,6 +323,7 @@ class PortalServicesController extends Controller
$resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
@@ -371,6 +381,9 @@ class PortalServicesController extends Controller
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
}
/**
* @param array<array-key, mixed> $filters
*/
private function filterManagedResources(Builder $query, array $filters): Builder
{
if (! empty($filters['q'])) {
@@ -384,23 +397,28 @@ class PortalServicesController extends Controller
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
}
}
return $query;
}
/**
* @return array<array-key, mixed>
*/
private function contentAssociations(Request $request): array
{
$access = app(\App\Services\DocumentManagementAccess::class);
$access = app(DocumentManagementAccess::class);
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
return [
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'sector' => Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
'stage' => collect(Config::array('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
];
}
@@ -445,7 +463,7 @@ class PortalServicesController extends Controller
$id = DB::transaction(function () use ($data, $request): int {
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -474,11 +492,11 @@ class PortalServicesController extends Controller
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -507,7 +525,7 @@ class PortalServicesController extends Controller
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$access = app(\App\Services\DocumentManagementAccess::class);
$access = app(DocumentManagementAccess::class);
if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id);
@@ -519,7 +537,9 @@ class PortalServicesController extends Controller
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
if ($type === 'resources') {
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
}
if ($type === 'resources' && $data['published']) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
@@ -832,17 +852,6 @@ class PortalServicesController extends Controller
&& DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
}
/** @return list<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> */
private function territoryScopeIds(string $id): array
{
@@ -866,17 +875,23 @@ SQL, [$id]);
return $ids ?: [$id];
}
private function articleUrl(int $id): ?string
{
if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null;
$resource = DB::table('portal_resources')->find($id);
if (!$resource) return null;
if ($resource->editorial_content !== null) return '/artigos/'.$id;
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
return null;
}
$resource = DB::table('portal_resources')->where('id', $id)->first();
if (! $resource) {
return null;
}
if ($resource->editorial_content !== null) {
return '/artigos/'.$id;
}
try {
return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException) {
return app(EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (HttpException) {
return null;
}
}
}
@@ -1,17 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\ProactiveSupply;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
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']);
$record=isset($v['record_id'])?Opportunity::findOrFail($v['record_id']):null;
return response()->json($service->search($request->user(),$request->all(),$record))->header('Cache-Control','private, no-store');
$v = $request->validate(['record_id' => 'nullable|integer|min:1']);
$record = isset($v['record_id']) ? Opportunity::whereKey($v['record_id'])->firstOrFail() : null;
return response()->json($service->search($request->user(), $request->all(), $record))->header('Cache-Control', 'private, no-store');
}
}
+27 -20
View File
@@ -3,22 +3,26 @@
namespace App\Http\Controllers;
use App\Services\Procurement;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementController extends Controller
{
private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
private function query(Procurement $service)
private function query(Procurement $service): Builder
{
abort_unless($service->available(), 404);
return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
}
public function index(Request $request, Procurement $service)
public function index(Request $request, Procurement $service): Response
{
$filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
@@ -48,9 +52,12 @@ class ProcurementController extends Controller
$options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values();
}
return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
return tap(Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
/**
* @return array<array-key, mixed>
*/
private function summary(Procurement $service, string $vid): array
{
$v = $service->publishedVersion($vid);
@@ -58,62 +65,62 @@ class ProcurementController extends Controller
return ['publication' => $v, 'state' => $service->state($v)];
}
public function show(Request $request, string $id, Procurement $service)
public function show(Request $request, string $id, Procurement $service): Response
{
return $this->detail($request, $id, $service, false);
}
public function print(Request $request, string $id, Procurement $service)
public function print(Request $request, string $id, Procurement $service): Response
{
return $this->detail($request, $id, $service, true);
}
private function detail(Request $request, string $id, Procurement $service, bool $print)
private function detail(Request $request, string $id, Procurement $service, bool $print): Response
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
$filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
return tap(Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function manage(Request $request, Procurement $service)
public function manage(Request $request, Procurement $service): Response
{
$service->guard($request->user());
return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
return tap(Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function edit(Request $request, Procurement $service, ?string $id = null)
public function edit(Request $request, Procurement $service, ?string $id = null): Response
{
$service->guard($request->user());
$r = $id ? $service->record($id) : null;
return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
return tap(Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function save(Request $request, Procurement $service, ?string $id = null)
public function save(Request $request, Procurement $service, ?string $id = null): RedirectResponse
{
$id = $service->save($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.');
}
public function preview(Request $request, string $id, Procurement $service)
public function preview(Request $request, string $id, Procurement $service): JsonResponse
{
return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store');
}
public function publish(Request $request, string $id, Procurement $service)
public function publish(Request $request, string $id, Procurement $service): RedirectResponse
{
$service->publish($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.');
}
public function watch(Request $request, string $id, Procurement $service)
public function watch(Request $request, string $id, Procurement $service): RedirectResponse
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']);
DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]);
@@ -121,7 +128,7 @@ class ProcurementController extends Controller
return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
}
public function unwatch(Request $request, string $id, Procurement $service)
public function unwatch(Request $request, string $id, Procurement $service): RedirectResponse
{
abort_unless($service->available(), 404);
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
@@ -129,10 +136,10 @@ class ProcurementController extends Controller
return back();
}
public function watched(Request $request, Procurement $service)
public function watched(Request $request, Procurement $service): Response
{
$rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]);
return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
return tap(Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
}
@@ -4,11 +4,13 @@ namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessClassification;
use App\Services\OpportunityClassification;
use App\Services\ExistingBusinessDrafts;
use App\Services\InvestmentGeography;
use App\Services\OpportunityClassification;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -43,8 +45,8 @@ class PublicBusinessOpportunityController extends Controller
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
}
if (! empty($filters['sector'])) {
$sector = \App\Models\Sector::find($filters['sector']);
$query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::whereKey($filters['sector'])->first();
$query->whereIn('existing_businesses.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($filters['modality'])) {
$query->where('existing_businesses.modality', $filters['modality']);
@@ -60,6 +62,9 @@ class PublicBusinessOpportunityController extends Controller
'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
}
/**
* @return array<array-key, mixed>
*/
private function filters(Request $request): array
{
return $request->validate([
@@ -2,21 +2,26 @@
namespace App\Http\Controllers;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PublicGeographyController extends Controller
{
public function options(Request $request, InvestmentGeography $geography): JsonResponse
{
$data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$data = $request->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = $geography->publicReferences()->where('location_type', $data['type']);
if (!empty($data['corridor'])) {
$corridors = app(\App\Services\InvestmentCorridorContext::class);
if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id'));
elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.');
if (! empty($data['corridor'])) {
$corridors = app(InvestmentCorridorContext::class);
if ($data['type'] === 'province') {
$query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id'));
} elseif (! $corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) {
abort(422, 'O território não pertence ao corredor escolhido.');
}
}
if (! empty($data['id'])) {
$query->where('id', $data['id']);
@@ -6,6 +6,8 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\SectorInvestment;
use App\Services\SectorOrganization;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -19,10 +21,14 @@ class PublicPortalController extends Controller
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']);
}
public function sector(string $slug): Response|\Illuminate\Http\RedirectResponse
public function sector(string $slug): Response|RedirectResponse
{
if (in_array($slug, ['energia', 'fintech'], true)) return redirect(\App\Services\SectorOrganization::href($slug), 301);
if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech';
if (in_array($slug, ['energia', 'fintech'], true)) {
return redirect(SectorOrganization::href($slug), 301);
}
if ($slug === 'mercado-financeiro/fintech') {
$slug = 'fintech';
}
$sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail();
return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]);
@@ -1,20 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Services\SectorReconciliationExecution;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
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);
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');
}
+20 -13
View File
@@ -10,6 +10,7 @@ use App\Services\CountryCatalogue;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers;
use App\Services\ScopedPortalAdministration;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
@@ -21,6 +22,7 @@ use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -148,7 +150,7 @@ class VirtualDeskController extends Controller
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'owner' => $desk->owns($request->user(), $case), 'manager' => app(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
@@ -178,24 +180,29 @@ class VirtualDeskController extends Controller
public function assignments(Request $request): JsonResponse|Response
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
// Triage projection deliberately excludes payloads, documents, investor identity and case titles.
$data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']),
'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
$data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed'])->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']),
'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
}
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']);
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 3 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.exists' => 'Seleccione um responsável com conta disponível.']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first();
abort_unless($row,404);
$case = DB::table('desk_cases')->where('id',$row->case_id)->first();
abort_unless($case,404);
if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
$row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
$case = DB::table('desk_cases')->where('id', $row->case_id)->first();
abort_unless($case !== null, 404);
if ((int) $row->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
}
if (in_array($row->status, ['draft', 'cancelled', 'closed'], true)) {
throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
}
$analyst = User::where('id', $data['analyst_id'])->firstOrFail();
abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.');
abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
@@ -12,7 +12,7 @@ class EnsureDocumentManagement
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
abort_unless($actor !== null, 403);
$access = app(DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request); // Preserve existing management controls.
@@ -1,37 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/** Environment reservation is not a portal identity or a business permission. */
class EnsureHomologationAccess
{
public function handle(Request $request, Closure $next): Response
{
if (!config('portal.homologation_only', false)) return $next($request);
$mode=config('portal.homologation_access_mode', 'reviewers');
if ($mode==='perimeter') {
// Set only by the trusted server after its environment-access check.
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403,
'Este ambiente de homologação tem acesso reservado à Tempo.');
$response=$next($request);
$response->headers->set('Cache-Control','private, no-store');
$response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive');
return $response;
}
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured.
if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*',
'email/verify','email/verify/*','email/verification-notification','two-factor-challenge',
'user/confirm-password','user/confirmed-password-status','user/two-factor-*',
'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request);
$actor=$request->user();
if (!$actor) return $request->expectsJson()
? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401)
: redirect()->guest(route('login'));
abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403,
'Esta homologação está reservada às contas indicadas pela Tempo.');
$response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response;
}
public function handle(Request $request, Closure $next): Response
{
if (! config('portal.homologation_only', false)) {
return $next($request);
}
$mode = config('portal.homologation_access_mode', 'reviewers');
if ($mode === 'perimeter') {
// Set only by the trusted server after its environment-access check.
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS') === 'granted', 403,
'Este ambiente de homologação tem acesso reservado à Tempo.');
$response = $next($request);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive');
return $response;
}
abort_unless($mode === 'reviewers', 503, 'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured.
if ($request->is('login', 'logout', 'register', 'forgot-password', 'reset-password', 'reset-password/*',
'email/verify', 'email/verify/*', 'email/verification-notification', 'two-factor-challenge',
'user/confirm-password', 'user/confirmed-password-status', 'user/two-factor-*',
'settings/security', 'settings/password', 'user/password', 'passkeys/*', 'user/passkeys/*', 'up')) {
return $next($request);
}
$actor = $request->user();
if (! $actor) {
return $request->expectsJson()
? response()->json(['message' => 'Entre na conta autorizada para homologação.'], 401)
: redirect()->guest(route('login'));
}
abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)), config('portal.homologation_reviewers', []), true), 403,
'Esta homologação está reservada às contas indicadas pela Tempo.');
$response = $next($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
@@ -1,15 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureScopedEditorial
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
$access = app(\App\Services\DocumentManagementAccess::class);
abort_unless($actor !== null, 403);
$access = app(DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request);
}
@@ -20,6 +24,7 @@ class EnsureScopedEditorial
if ($request->route('id') !== null) {
$access->assertEditableResource($actor, (int) $request->route('id'));
}
return $next($request);
}
}
@@ -64,6 +64,7 @@ final class EnsureSimulationRecoveryCookie
false,
'lax',
));
return $response;
}
+15 -11
View File
@@ -3,10 +3,13 @@
namespace App\Http\Middleware;
use App\Models\ExistingBusiness;
use App\Services\ContentManagement;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use App\Services\Procurement;
use App\Services\ScopedPortalAdministration;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -48,15 +51,16 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$contentManagementAccess=app(\App\Services\ContentManagement::class)->access($request->user());
$contentManagementAccess = app(ContentManagement::class)->access($request->user());
return [
...parent::share($request),
'name' => config('app.name'),
'procurementEnabled' => app(\App\Services\Procurement::class)->available(),
'procurementEnabled' => app(Procurement::class)->available(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
'contentManagementAccess'=>$contentManagementAccess,
'contentManagementAccess' => $contentManagementAccess,
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -73,14 +77,14 @@ class HandleInertiaRequests extends Middleware
'installationHref' => '/management/opportunity-management-install',
] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')],
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
['label'=>'Referências e formulários','href'=>'/management/investment/references'],
['label'=>'Educação e competências','href'=>'/management/education'],
['label'=>'Interesses atribuídos','href'=>'/management/interests'],
['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'],
['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'],
'scopedAdministration' => $request->user() && app(ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label' => 'Zonas e parques', 'href' => '/management/investment/zones'],
['label' => 'Referências económicas', 'href' => '/management/economic-references/lot-01'],
['label' => 'Referências e formulários', 'href' => '/management/investment/references'],
['label' => 'Educação e competências', 'href' => '/management/education'],
['label' => 'Interesses atribuídos', 'href' => '/management/interests'],
['label' => 'Atribuições de interesses', 'href' => '/management/interests/atribuicoes'],
['label' => 'Atribuições do Balcão', 'href' => '/management/balcao/atribuicoes'],
] : [],
'auth' => [
'user' => $request->user(),
+6 -2
View File
@@ -13,15 +13,19 @@ class PortalProfileSession
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return $next($request);
}
$access = app(PortalProfileAccess::class);
$available = $access->governanceAvailable();
if ($request->user() && (config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) {
if ((config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) {
$recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap')
|| ($request->is('logout') && $request->isMethod('POST'));
abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503,
'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.');
}
if ($request->user() && $available) {
if ($available) {
$revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
$bound = $request->session()->get('portal.access_revision');
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
@@ -13,15 +13,22 @@ class VerifiedOrSimpleParticipation
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()->hasVerifiedEmail()) return $next($request);
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request);
if ($request->user()->hasVerifiedEmail()) {
return $next($request);
}
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) {
return $next($request);
}
$id = $request->route('id');
$show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id);
$message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages');
if ($id && ($show || $message)) {
$row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail();
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request);
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) {
return $next($request);
}
}
return app(EnsureEmailIsVerified::class)->handle($request, $next);
}
}
@@ -6,8 +6,10 @@ namespace App\Http\Requests;
use App\Models\LandAssistanceCase;
use App\Services\CountryCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentGeography;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
@@ -19,9 +21,14 @@ class StoreLandAssistanceCaseRequest extends FormRequest
{
$payload = $this->validated('payload');
if (!is_array($payload)) return [];
if (!empty($payload['investor_path_case_id'])) return app(\App\Services\InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support'));
return \Illuminate\Support\Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
if (! is_array($payload)) {
return [];
}
if (! empty($payload['investor_path_case_id'])) {
return app(InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support'));
}
return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
}
public function authorize(): bool
@@ -2,6 +2,7 @@
namespace App\Http\Resources;
use App\Services\ExistingBusinessClassification;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -27,7 +28,7 @@ class PublicBusinessOpportunityResource extends JsonResource
'province' => $this->resource->getAttribute('province'),
'district' => $this->resource->getAttribute('district'),
'locality' => $this->resource->getAttribute('locality'),
'multidimensional' => app(\App\Services\ExistingBusinessClassification::class)->publicRows($this->resource),
'multidimensional' => app(ExistingBusinessClassification::class)->publicRows($this->resource),
];
}
}
+6
View File
@@ -2,4 +2,10 @@
namespace App\Models;
/**
* @property string|null $external_id
* @property string|null $source_system
* @property string $full_name
* @property string|null $phone
*/
class Contact extends CrmRecord {}
+1
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model;
* @property string $validation_status
* @property string $valid_from
* @property string|null $valid_until
* @property array<string, mixed>|null $fiscal_acceptance
*/
class InvestmentLegalSource extends Model
{
+1 -1
View File
@@ -24,7 +24,7 @@ class InvestmentZone extends DomainRecord
{
protected $hidden = ['editorial_draft', 'operational_details'];
protected $casts = ['operational_details' => 'array', 'operational_details' => 'array', 'version_no' => 'integer', 'published_at' => 'datetime', 'aliases' => 'array', 'activities' => 'array', 'resources' => 'array', 'historical_territory' => 'array', 'area_components' => 'array', 'source_evidence' => 'array', 'area_value' => 'decimal:4', 'data_as_of' => 'date', 'sources_consulted_at' => 'date'];
protected $casts = ['operational_details' => 'array', 'version_no' => 'integer', 'published_at' => 'datetime', 'aliases' => 'array', 'activities' => 'array', 'resources' => 'array', 'historical_territory' => 'array', 'area_components' => 'array', 'source_evidence' => 'array', 'area_value' => 'decimal:4', 'data_as_of' => 'date', 'sources_consulted_at' => 'date'];
/** @return BelongsTo<GeoLocation, $this> */
public function primaryTerritory(): BelongsTo
+11 -3
View File
@@ -2,6 +2,9 @@
namespace App\Models;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityPublicText;
use App\Services\ProactiveInvestment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -34,8 +37,13 @@ class Opportunity extends Model
public function toArray(): array
{
$data = parent::toArray();
if (isset($data['description'])) $data['description'] = \App\Services\OpportunityPublicText::description($this, $data['description']);
if ($this->exists && ($proactive = app(\App\Services\ProactiveInvestment::class)->publicData($this))) $data['proactive'] = $proactive;
if (isset($data['description'])) {
$data['description'] = OpportunityPublicText::description($this, $data['description']);
}
if ($this->exists && ($proactive = app(ProactiveInvestment::class)->publicData($this))) {
$data['proactive'] = $proactive;
}
return $data;
}
@@ -44,7 +52,7 @@ class Opportunity extends Model
// Only published metadata is public; never expose the current private draft.
$version = data_get($this->workflow_context, 'publication.maturity.catalogue_version');
return in_array($version, [\App\Services\OpportunityMaturity::VERSION, \App\Services\OpportunityMaturity::LEGACY], true)
return in_array($version, [OpportunityMaturity::VERSION, OpportunityMaturity::LEGACY], true)
? $version : null;
}
+15 -4
View File
@@ -2,10 +2,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property list<array<string, mixed>> $specializations
* @property string $url
*/
class Sector extends Model
{
use SoftDeletes;
@@ -20,18 +25,24 @@ class Sector extends Model
'published_at' => 'datetime',
];
/** Presentation only: historical masters and foreign keys remain untouched. */
public function scopeChoices($query)
/** Presentation only: historical masters and foreign keys remain untouched.
* @param Builder<Sector> $query
* @return Builder<Sector>
*/
public function scopeChoices(Builder $query): Builder
{
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');
}
public function getNameAttribute($value)
public function getNameAttribute(?string $value): ?string
{
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 $scope_key
* @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|null $reviewer_id
* @property int|null $approver_id
+16 -7
View File
@@ -4,6 +4,7 @@ namespace App\Policies;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
@@ -87,11 +88,13 @@ class ExistingBusinessPolicy
return $this->designatedAny($user);
}
/** Filter before projecting or decrypting private fields in the review queue. */
/** Filter before projecting or decrypting private fields in the review queue.
* @param Builder<ExistingBusiness> $query
*/
public function scopeReviewQueue(Builder $query, User $user): void
{
if (app(PortalProfileAccess::class)->available()) {
$query->whereIn('modality',array_keys(\App\Services\ExistingBusinessProfileCategory::MAP));
$query->whereIn('modality', array_keys(ExistingBusinessProfileCategory::MAP));
$ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys();
$query->whereKey($ids);
}
@@ -119,13 +122,18 @@ class ExistingBusinessPolicy
return false;
}
$access = app(PortalProfileAccess::class);
if ($access->has($user, 'auditor')) { return false; }
if ($access->has($user, 'auditor')) {
return false;
}
if (! $access->available()) {
return $access->legacyWriteAllowed($user) && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret
&& in_array(strtolower($user->email), config('existing-business.reviewers', []), true);
}
if ($business->modality !== 'company_sale') { return false; }
if ($business->modality !== 'company_sale') {
return false;
}
$responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
return collect($access->grants($user, 'validator'))->contains(fn ($g) => $g->organization_id === null
&& in_array('business_sale', json_decode($g->opportunity_types, true), true)
&& in_array($responsibility, json_decode($g->responsibilities, true), true));
@@ -137,6 +145,7 @@ class ExistingBusinessPolicy
|| app(PortalProfileAccess::class)->has($user, 'auditor') || ! $this->designated($user, 'publishers', $business)) {
return false;
}
return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists();
}
@@ -156,8 +165,8 @@ class ExistingBusinessPolicy
{
$access = app(PortalProfileAccess::class);
if ($access->available()) {
$category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) {
$category = $business ? ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user, 'auditor')) {
return false;
}
$role = $designation === 'publishers' ? 'publisher' : 'validator';
@@ -168,7 +177,7 @@ class ExistingBusinessPolicy
$organizationMatches = $grant->organization_id === null
|| ($business !== null && $grant->organization_id === $business->organization_id);
if ($organizationMatches
&& ($category !== null ? in_array($category,json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR),true) : array_intersect(array_values(\App\Services\ExistingBusinessProfileCategory::MAP),json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR)) !== [])) {
&& ($category !== null ? in_array($category, json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true) : array_intersect(array_values(ExistingBusinessProfileCategory::MAP), json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR)) !== [])) {
return true;
}
}
+6 -2
View File
@@ -22,6 +22,8 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LogoutResponse;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
class AppServiceProvider extends ServiceProvider
@@ -45,12 +47,12 @@ class AppServiceProvider extends ServiceProvider
// Fortify calls this response AFTER invalidating/regenerating the server session.
// Clear the client's encryption key too, so Back must reauthorize private pages.
$this->app->bind(\Laravel\Fortify\Contracts\LogoutResponse::class, function () {
$this->app->bind(LogoutResponse::class, function () {
return new class extends \Laravel\Fortify\Http\Responses\LogoutResponse
{
public function toResponse($request)
{
\Inertia\Inertia::clearHistory();
Inertia::clearHistory();
return parent::toResponse($request);
}
@@ -77,6 +79,8 @@ class AppServiceProvider extends ServiceProvider
Cache::forget('portal_home_en');
Cache::forget('portal_home_v2_pt');
Cache::forget('portal_home_v2_en');
Cache::forget('portal_home_sector_organization_v1_pt');
Cache::forget('portal_home_sector_organization_v1_en');
};
$model::saved($invalidate);
$model::deleted($invalidate);
+2 -2
View File
@@ -24,8 +24,8 @@ class InvestorGuidance
return Schema::hasColumn('tasks', 'desk_case_id');
}
/** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator. */
/** @return array<string,mixed> */
/** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator.
* @return array<string,mixed> */
public function context(User $actor, \stdClass $case): array
{
$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.
$rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100',
function ($attribute, $value, $fail) {
if (strlen((string) $value) > 8) $fail('Indique a percentagem com até 8 caracteres.');
if (strlen((string) $value) > 8) {
$fail('Indique a percentagem com até 8 caracteres.');
}
}];
foreach (['valid_until', 'mandate_valid_until'] as $date) {
$rules['payload.'.$date][] = 'date_format:Y-m-d';
+25 -3
View File
@@ -23,17 +23,26 @@ final class ClusterCompetitiveness
->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)
: ['version' => 0, 'draft' => null, 'approved' => null];
}
/**
* @return array<array-key, mixed>
*/
public function current(string $cluster): array
{
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
}
/**
* @return array<array-key, mixed>
*/
public function management(string $cluster): array
{
$state = $this->current($cluster);
@@ -44,6 +53,10 @@ final class ClusterCompetitiveness
return $state;
}
/**
* @param array<array-key, mixed> $draft
* @return array<array-key, mixed>
*/
public function validateDraft(array $draft, string $cluster): array
{
$rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster,
@@ -69,6 +82,9 @@ final class ClusterCompetitiveness
return $clean;
}
/**
* @param array<array-key, mixed>|null $draft
*/
private function assertComplete(?array $draft): void
{
$missing = ! $draft || ! $draft['sector'] || ! $draft['region'];
@@ -84,7 +100,10 @@ final class ClusterCompetitiveness
}
}
/** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. */
/** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields.
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
{
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
@@ -96,7 +115,7 @@ final class ClusterCompetitiveness
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event, 404);
abort_unless($event !== null, 404);
$state['draft'] = $this->decode($event)['draft'];
} elseif ($action === 'approved') {
$this->assertComplete($state['draft']);
@@ -120,6 +139,9 @@ final class ClusterCompetitiveness
});
}
/**
* @return array<array-key, mixed>|null
*/
public function publicData(string $cluster): ?array
{
$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. */
final class ContentManagement
{
/**
* @return array<array-key, mixed>
*/
public function access(?User $actor): array
{
if (! $actor || ! $actor->hasVerifiedEmail()) return [];
if (! $actor || ! $actor->hasVerifiedEmail()) {
return [];
}
$profiles = app(PortalProfileAccess::class);
$navigation = $profiles->navigation($actor);
$legacy = $profiles->legacyWriteAllowed($actor);
@@ -19,15 +24,23 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) {
$result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
}
if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) {
$result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades',
'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy];
}
if ($upload) $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
if ($upload) {
$result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
}
return $result;
}
/**
* @return array<array-key, mixed>
*/
public function view(User $actor): array
{
$access = $this->access($actor);
@@ -45,6 +58,7 @@ final class ContentManagement
$counts['documents'] = $query ? $query->select('r.published', DB::raw('count(*) as total'))->groupBy('r.published')->get()
->mapWithKeys(fn ($row) => [$row->published ? 'published' : 'unpublished' => (int) $row->total]) : [];
}
return ['access' => $access, 'counts' => $counts];
}
}
+5 -1
View File
@@ -2,15 +2,19 @@
namespace App\Services;
use App\Support\CheckedFile;
use DOMDocument;
use DOMElement;
/** Supplement the six named agribusiness sections without changing corridor masters or source documents. */
final class CorridorAgribusiness
{
/**
* @param array<array-key, mixed> $links
*/
public function enrich(string $html, array $links = []): string
{
$data = json_decode(file_get_contents(__DIR__.'/../../database/data/corridor-investment-opportunities.json'), true, 512, JSON_THROW_ON_ERROR);
$data = json_decode(CheckedFile::read(__DIR__.'/../../database/data/corridor-investment-opportunities.json'), true, 512, JSON_THROW_ON_ERROR);
$doc = new DOMDocument;
$previous = libxml_use_internal_errors(true);
try {
@@ -2,9 +2,11 @@
namespace App\Services;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
@@ -14,22 +16,33 @@ final class CorridorAgribusinessOpportunities
{
public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055';
/** Documentary cross-reference only; keep the two source designations and master records intact. */
public function portfolioOpportunityIds(\App\Models\InvestmentDossier $dossier): array
/** Documentary cross-reference only; keep the two source designations and master records intact.
* @return array<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
// grouped under Corredor da Beira in the accepted source, not selected by province/name search.
$keys = collect($this->entries())->where('corridor', 'Corredor da Beira')->pluck('key');
return Opportunity::published()->whereIn('management_creation_key', $keys)
->where('workflow_context->corridor_agribusiness->source_sha256', self::SOURCE_HASH)->pluck('id')->all();
}
public const LIMITATIONS = 'Promotor, implantação exacta, área, montante, financiamento, viabilidade e licenças não indicados nesta fonte. O corredor é um enquadramento documental; não identifica uma parcela nem confirma disponibilidade.';
/**
* @return array<array-key, mixed>
*/
public function entries(): array
{
$data = json_decode(file_get_contents(database_path('data/corridor-investment-opportunities.json')), true, 512, JSON_THROW_ON_ERROR);
if ($data['source_sha256'] !== self::SOURCE_HASH) throw new \RuntimeException('Fonte divergente; reconciliar antes de cadastrar.');
$data = json_decode(CheckedFile::read(database_path('data/corridor-investment-opportunities.json')), true, 512, JSON_THROW_ON_ERROR);
if ($data['source_sha256'] !== self::SOURCE_HASH) {
throw new \RuntimeException('Fonte divergente; reconciliar antes de cadastrar.');
}
$sector = Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNull('deleted_at')->firstOrFail();
$entries = [];
foreach ($data['groups'] as $g => $group) {
@@ -42,14 +55,21 @@ final class CorridorAgribusinessOpportunities
'summary' => $item['description'], 'description' => $description, 'sector_id' => $sector->id];
}
}
if (count($entries) !== 34) throw new \RuntimeException('O lote deve conter as 34 entradas da fonte.');
if (count($entries) !== 34) {
throw new \RuntimeException('O lote deve conter as 34 entradas da fonte.');
}
return $entries;
}
/**
* @return array<array-key, mixed>
*/
public function register(User $actor, string $organization): array
{
// The caller uses the ordinary profile creation route and its approved scope.
abort_unless(str_starts_with(request()->path(), 'minhas-oportunidades'), 422);
return DB::transaction(function () use ($actor, $organization) {
DB::select('SELECT pg_advisory_xact_lock(231026, 34)');
$result = [];
@@ -60,6 +80,7 @@ final class CorridorAgribusinessOpportunities
abort_unless(data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') === self::SOURCE_HASH, 409);
// Recovery never overwrites editorial changes, withdrawal or archive.
$result[] = ['id' => $record->id, 'created' => false, 'key' => $entry['key']];
continue;
}
$record = app(OpportunityManagement::class)->save($actor, ['creation_key' => $entry['key'], 'organization_id' => $organization,
@@ -81,17 +102,29 @@ final class CorridorAgribusinessOpportunities
'after_data' => json_encode(['record_id' => $record->id, 'source' => $entry, 'no_publication' => true, 'no_master_relation_inferred' => true], JSON_THROW_ON_ERROR)]);
$result[] = ['id' => $record->id, 'created' => true, 'key' => $entry['key']];
}
return $result;
});
}
/**
* @return array<array-key, mixed>
*/
public function articleLinks(): array
{
// Reading an original article must work before the optional sector/opportunity catalogue is loaded.
if (! Sector::where('slug', 'agronegocio')->where('is_active', true)->exists()) {
return [];
}
$records = Opportunity::published()->whereIn('management_creation_key', array_column($this->entries(), 'key'))->get()->keyBy('management_creation_key');
$links = [];
foreach ($this->entries() as $entry) {
if ($record = $records->get($entry['key'])) $links[$entry['group']][$entry['index']] = '/oportunidades/'.$record->slug;
if ($record = $records->get($entry['key'])) {
$links[$entry['group']][$entry['index']] = '/oportunidades/'.$record->slug;
}
}
return $links;
}
}
+2 -2
View File
@@ -15,8 +15,8 @@ final class DeclaredOriginStatistics
abort_unless(app(ScopedPortalAdministration::class)->interestOperator($actor), 403);
$names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code');
$groups = [];
$interests=DB::table('interest_requests')->whereNull('deleted_at');
app(ScopedPortalAdministration::class)->interestQuery($interests,$actor);
$interests = DB::table('interest_requests')->whereNull('deleted_at');
app(ScopedPortalAdministration::class)->interestQuery($interests, $actor);
foreach ($interests->select('metadata')->cursor() as $row) {
$metadata = json_decode($row->metadata ?? '{}', true);
$code = $metadata['declared_origin_country_code'] ?? null;
+6 -1
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
@@ -37,6 +38,7 @@ class DocumentManagementAccess
public function editorialResources(User $actor): Builder
{
abort_unless($this->editorial($actor), 403);
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted')
->where(function ($query) use ($actor) {
@@ -50,7 +52,7 @@ class DocumentManagementAccess
->where('d.external_id', $item['external_id']);
});
}
foreach (json_decode(file_get_contents(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $item) {
foreach (json_decode(CheckedFile::read(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $item) {
// These exact original bytes contain the legacy identity/source.
// Destination upload IDs differ; the approved file scope does not.
$query->orWhere('d.sha256', $item['sha256']);
@@ -80,6 +82,9 @@ class DocumentManagementAccess
return $query;
}
/**
* @param array<array-key, mixed> $data
*/
public function audit(User $actor, string $action, array $data): void
{
DB::table('audit_events')->insert([
+59 -17
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Support\CheckedFile;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -11,14 +12,19 @@ use Illuminate\Support\Facades\Schema;
class EconomicReferenceCatalog
{
public const LOT = 'lot-01';
public const SOURCE = 'economic-reference-lot-01';
public const MANIFEST_SHA256 = 'ba1bb3406fd55e39c19fd2a2755f3dfc2a1160b67eee8b3b6e12cd43f4223b04';
/**
* @return array<array-key, mixed>
*/
public function manifest(): array
{
$file = database_path('data/economic-references-lot-01.json');
abort_unless(is_file($file) && hash_equals(self::MANIFEST_SHA256, hash_file('sha256', $file)), 422, 'Manifesto económico ausente ou diferente da revisão delimitada.');
$manifest = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
abort_unless(is_file($file) && hash_equals(self::MANIFEST_SHA256, CheckedFile::sha256($file)), 422, 'Manifesto económico ausente ou diferente da revisão delimitada.');
$manifest = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
abort_unless(($manifest['lot'] ?? '') === self::LOT && count($manifest['activities'] ?? []) === 5
&& count($manifest['chains'] ?? []) === 5 && count($manifest['links'] ?? []) === 4, 422, 'Lote económico fora do âmbito.');
@@ -40,7 +46,9 @@ class EconomicReferenceCatalog
public function available(): bool
{
if (! $this->schemaAvailable() || $this->publicationState() !== 'published') { return false; }
if (! $this->schemaAvailable() || $this->publicationState() !== 'published') {
return false;
}
return DB::table('reference_catalog_versions')->where('id', $this->manifest()['catalog']['id'])->where('status', 'published')->exists();
}
@@ -48,7 +56,9 @@ class EconomicReferenceCatalog
public function constrain(Builder $query, string $kind): Builder
{
$manifest = $this->manifest();
if ($kind === 'activity') $query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID);
if ($kind === 'activity') {
$query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID);
}
if (in_array($kind, ['activity', 'chain'], true) && ! $this->available()) {
$query->whereNotIn('id', array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'));
}
@@ -59,17 +69,29 @@ class EconomicReferenceCatalog
/** A withdrawn published reference can be retained verbatim, never introduced as a new choice. */
public function historicalTarget(string $kind, string $id): bool
{
if ($kind === 'activity' && $id === SectorOrganization::LEGACY_ACTIVITY_ID) return DB::table('economic_activities')->where('id', $id)->where('sector_id', 2)->where('status', 'published')->exists();
if (! in_array($kind, ['activity', 'chain'], true) || ! $this->schemaAvailable()) { return false; }
if ($kind === 'activity' && $id === SectorOrganization::LEGACY_ACTIVITY_ID) {
return DB::table('economic_activities')->where('id', $id)->where('sector_id', 2)->where('status', 'published')->exists();
}
if (! in_array($kind, ['activity', 'chain'], true) || ! $this->schemaAvailable()) {
return false;
}
$manifest = $this->manifest();
if (! in_array($id, array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'), true)) { return false; }
if (! DB::table('audit_events')->where('source_system', self::SOURCE)->where('action', 'publish')->exists()) { return false; }
if (! in_array($id, array_column($manifest[$kind === 'activity' ? 'activities' : 'chains'], 'id'), true)) {
return false;
}
if (! DB::table('audit_events')->where('source_system', self::SOURCE)->where('action', 'publish')->exists()) {
return false;
}
return $kind === 'activity'
? DB::table('economic_activities')->where('id', $id)->where('status', 'published')->exists()
: DB::table('value_chains')->where('id', $id)->whereNull('deleted_at')->exists();
}
/**
* @param array<array-key, mixed> $historicalIds
* @return Collection<int, \stdClass>
*/
public function activities(array $historicalIds = []): Collection
{
$rows = DB::table('economic_activities')->where('status', 'published')->get();
@@ -79,14 +101,19 @@ class EconomicReferenceCatalog
foreach ($historicalIds as $id) {
$seen = [];
while (isset($byId[$id]) && ! isset($seen[$id])) {
$allowed[] = $id; $seen[$id] = true; $id = $byId[$id]->parent_id;
$allowed[] = $id;
$seen[$id] = true;
$id = $byId[$id]->parent_id;
}
}
$available = $this->available();
return $rows->filter(fn ($r) => in_array($r->id, $allowed, true))->map(function ($row) use ($byId, $available) {
return $rows->filter(fn ($r) => in_array($r->id, $allowed, true))->map(function ($row) use ($byId, $available): \stdClass {
$meta = property_exists($row, 'metadata') ? json_decode($row->metadata, true) : [];
$path = []; $current = $row; $seen = [];
$path = [];
$current = $row;
$seen = [];
while ($current && ! isset($seen[$current->id])) {
$seen[$current->id] = true;
array_unshift($path, ['id' => $current->id, 'code' => $current->code, 'label' => $current->label_pt_mz]);
@@ -94,21 +121,31 @@ class EconomicReferenceCatalog
}
$isLot = ($meta['lot'] ?? '') === self::LOT;
return (object) ['id' => $row->id, 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
$option = ['id' => $row->id, 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
'label_pt_mz' => $row->label_pt_mz, 'catalog_version_id' => $row->catalog_version_id, 'code' => $row->code,
'level' => $meta['level'] ?? null, 'path' => $path, 'display_label' => implode(' → ', array_column($path, 'code')).' — '.$row->label_pt_mz,
'source_version' => $meta['source_version'] ?? null, 'edition' => $meta['edition'] ?? null,
'historical_only' => $isLot && ! $available];
$result = new \stdClass;
foreach ($option as $key => $value) {
$result->{$key} = $value;
}
return $result;
})->values();
}
public function validHierarchy(int $sectorId, ?string $subsectorId, ?string $activityId, bool $historical = false): bool
{
if (! $subsectorId || ! $activityId) { return false; }
if (! $subsectorId || ! $activityId) {
return false;
}
$sector = DB::table('sectors')->where('id', $sectorId)->whereNull('deleted_at')->where('is_active', true)->exists();
$subsector = DB::table('economic_activities')->where('id', $subsectorId)->where('status', 'published')->first();
$activity = DB::table('economic_activities')->where('id', $activityId)->where('status', 'published')->first();
if (! $sector || ! $subsector || ! $activity || $activity->catalog_version_id !== $subsector->catalog_version_id) { return false; }
if (! $sector || ! $subsector || ! $activity || $activity->catalog_version_id !== $subsector->catalog_version_id) {
return false;
}
$manifest = $this->manifest();
if ($activity->catalog_version_id !== $manifest['catalog']['id']) {
$root = $subsector->parent_id ? DB::table('economic_activities')->where('id', $subsector->parent_id)->where('status', 'published')->first() : null;
@@ -116,10 +153,15 @@ class EconomicReferenceCatalog
return $root && (int) $root->sector_id === $sectorId && $activity->parent_id === $subsector->id;
}
if ((! $this->available() && ! $historical) || $sectorId !== (int) $manifest['existing']['sector']['id']
|| $subsector->code !== '01' || $activity->code !== '01120') { return false; }
$current = $activity; $codes = []; $seen = [];
|| $subsector->code !== '01' || $activity->code !== '01120') {
return false;
}
$current = $activity;
$codes = [];
$seen = [];
while ($current && ! isset($seen[$current->id])) {
$seen[$current->id] = true; array_unshift($codes, $current->code);
$seen[$current->id] = true;
array_unshift($codes, $current->code);
$current = $current->parent_id ? DB::table('economic_activities')->where('id', $current->parent_id)->where('catalog_version_id', $activity->catalog_version_id)->where('status', 'published')->first() : null;
}
+192 -61
View File
@@ -4,19 +4,22 @@ namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Bounded authenticated extension. No generic seeding, arbitrary path or caller-supplied actor. */
class EconomicReferenceInstallation
{
public const MIGRATION = '2026_10_16_120000_extend_economic_reference_hierarchy';
public const OPERATIONS = ['schema', 'import', 'publish', 'withdraw'];
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Services/EconomicReferenceCatalog.php', 'app/Services/EconomicReferenceInstallation.php',
@@ -32,13 +35,22 @@ class EconomicReferenceInstallation
return hash('sha256', json_encode(array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()), JSON_THROW_ON_ERROR));
}
private function directory(): string { return storage_path('app/private/investmoz-v2/economic-references/lot-01'); }
private function directory(): string
{
return storage_path('app/private/investmoz-v2/economic-references/lot-01');
}
/**
* @return array<array-key, mixed>
*/
private function target(): array
{
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
{
return app()->environment('testing') && str_ends_with($target['database'], '_test') && (int) $target['port'] === 55441
@@ -48,20 +60,32 @@ class EconomicReferenceInstallation
private function authorize(User $actor, bool $write = false, string $operation = ''): void
{
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Acesso reservado à gestão autorizada.');
if (! $write) { return; }
if (! $write) {
return;
}
$access = app(PortalProfileAccess::class);
abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.');
abort_unless($access->mfa($actor), 403, 'Conclua MFA nesta sessão para executar esta ação.');
if ($operation === 'schema') { app(PortalProfileInstallation::class)->authorize($actor, true); }
if ($operation === 'schema') {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
abort_unless(! app()->environment('testing') || $this->qa($this->target()), 403, 'Alvo QA isolado inválido.');
}
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code, string $operation): array
{
if ($this->qa($target)) { return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null]; }
if (app()->environment('testing')) { return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null]; }
if ($this->qa($target)) {
return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null];
}
if (app()->environment('testing')) {
return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null];
}
$path = $this->directory().'/readiness.json';
$proof = is_file($path) ? json_decode(file_get_contents($path), true) : [];
$proof = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
$date = strtotime($proof['verified_at'] ?? '');
$permission = $proof['active_authorization'] ?? [];
$ready = ! config('database.connections.pgsql.url') && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
@@ -73,8 +97,8 @@ class EconomicReferenceInstallation
&& ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& str_ends_with($proof['restore_target']['database'] ?? '', '_test') && (int) ($proof['restore_target']['port'] ?? 0) === 55441
&& is_file($proof['dump_path'] ?? '') && is_file($proof['files_path'] ?? '')
&& hash_equals($proof['dump_sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
&& hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))
&& hash_equals($proof['dump_sha256'] ?? '', CheckedFile::sha256($proof['dump_path']))
&& hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))
&& ($permission['decision_record'] ?? '') !== '' && ($permission['code_hash'] ?? '') === $code
&& ($permission['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256
&& in_array($operation, $permission['operations'] ?? [], true);
@@ -83,25 +107,35 @@ class EconomicReferenceInstallation
'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/** No source scan/write in preview. Paths are fixed, private and confined to this lot. */
/** No source scan/write in preview. Paths are fixed, private and confined to this lot.
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function sources(array $manifest): array
{
$root = realpath($this->directory()); $sources = [];
$root = realpath($this->directory());
$sources = [];
foreach ($manifest['sources'] as $entry) {
$path = $root ? realpath($root.DIRECTORY_SEPARATOR.$entry['relative_path']) : false;
$inside = $path && str_starts_with(strtolower(str_replace('\\', '/', $path)), strtolower(str_replace('\\', '/', $root)).'/');
$matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], hash_file('sha256', $path));
$matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], CheckedFile::sha256($path));
$sources[] = ['name' => $entry['name'], 'sha256' => $entry['sha256'], 'bytes' => $entry['bytes'], 'ready' => (bool) $matches];
}
return $sources;
}
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function referenceRows(array $manifest): array
{
$rows = [['table' => 'reference_catalog_versions', 'data' => $manifest['catalog']]];
foreach (['activities' => 'economic_activities', 'chains' => 'value_chains', 'links' => 'value_chain_links'] as $group => $table) {
foreach ($manifest[$group] as $row) { $rows[] = ['table' => $table, 'data' => $row]; }
foreach ($manifest[$group] as $row) {
$rows[] = ['table' => $table, 'data' => $row];
}
}
return $rows;
@@ -113,41 +147,67 @@ class EconomicReferenceInstallation
->whereRaw("after_data->'result'->'changed' @> ?::jsonb", [json_encode([$id])])->exists();
}
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function references(array $manifest, bool $schemaReady): array
{
$results = [];
foreach ($this->referenceRows($manifest) as $row) {
$expected = $row['data']; $table = $row['table'];
$expected = $row['data'];
$table = $row['table'];
$current = DB::table($table)->where('id', $expected['id'])->first();
$conflict = false;
foreach ($expected as $key => $value) {
if (! $current || ($key === 'metadata' && ! $schemaReady)) { continue; }
if ($key === 'status') { $conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true); continue; }
if ($key === 'data_classification') { $conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true); continue; }
if (! $current || ($key === 'metadata' && ! $schemaReady)) {
continue;
}
if ($key === 'status') {
$conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true);
continue;
}
if ($key === 'data_classification') {
$conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true);
continue;
}
$actual = $key === 'metadata' ? json_decode($current->$key, true) : $current->$key;
if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) { $conflict = true; }
if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) {
$conflict = true;
}
}
$identity = DB::table($table)->where('id', '!=', $expected['id']);
if ($table === 'reference_catalog_versions') { $identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']); }
elseif ($table === 'economic_activities') { $identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']); }
else { $identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']); }
if ($table === 'reference_catalog_versions') {
$identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']);
} elseif ($table === 'economic_activities') {
$identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']);
} else {
$identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']);
}
$conflict = $conflict || $identity->exists();
if ($table === 'value_chains') {
$conflict = $conflict || DB::table($table)->where('id', '!=', $expected['id'])->where('cluster_id', $expected['cluster_id'])->where('code', $expected['code'])->whereNull('deleted_at')->exists();
}
$results[] = ['table' => $table, 'id' => $expected['id'], 'code' => $expected['code'] ?? $expected['external_id'] ?? $expected['catalog_code'],
'status' => $conflict ? 'conflict' : ($current ? 'reuse' : 'insert'), 'current_hash' => $current ? OpportunityCanonicalValue::hash((array) $current) : null,
'current_visibility' => $current?->status ?? $current?->data_classification ?? null];
'current_visibility' => $current->status ?? $current->data_classification ?? null];
}
return $results;
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);
$catalog = app(EconomicReferenceCatalog::class); $manifest = $catalog->manifest();
$target = $this->target(); $code = $this->fingerprint();
$catalog = app(EconomicReferenceCatalog::class);
$manifest = $catalog->manifest();
$target = $this->target();
$code = $this->fingerprint();
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$column = $catalog->schemaAvailable();
$unique = DB::selectOne("SELECT 1 FROM pg_constraint WHERE conrelid='public.economic_activities'::regclass AND conname='economic_activities_sector_uq'") !== null;
@@ -155,19 +215,31 @@ class EconomicReferenceInstallation
$index = DB::selectOne("SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='economic_activities_sector_idx'") !== null;
$installed = $ledger && $column && ! $unique && $sectorNullable && $index;
$consistent = $installed || (! $ledger && ! $column && $unique && ! $sectorNullable && ! $index);
$rows = $this->references($manifest, $column); $sources = $this->sources($manifest); $reasons = [];
$rows = $this->references($manifest, $column);
$sources = $this->sources($manifest);
$reasons = [];
foreach (['sector' => 'sectors', 'cluster' => 'economic_clusters'] as $name => $table) {
$expected = $manifest['existing'][$name]; $record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first();
$expected = $manifest['existing'][$name];
$record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first();
foreach (array_filter($expected, fn ($v) => $v !== null) as $field => $value) {
if (! $record || (string) ($record->$field ?? '') !== (string) $value) { $reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.'; break; }
if (! $record || (string) ($record->$field ?? '') !== (string) $value) {
$reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.';
break;
}
}
}
if (! $consistent) { $reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.'; }
if (collect($rows)->contains('status', 'conflict')) { $reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.'; }
if (collect($sources)->contains('ready', false)) { $reasons[] = 'Exemplar privado ausente ou com bytes diferentes.'; }
if (! $consistent) {
$reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.';
}
if (collect($rows)->contains('status', 'conflict')) {
$reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.';
}
if (collect($sources)->contains('ready', false)) {
$reasons[] = 'Exemplar privado ausente ou com bytes diferentes.';
}
$imported = ! collect($rows)->contains(fn ($r) => $r['status'] !== 'reuse');
$scanPath = $this->directory().'/scans.json';
$scan = is_file($scanPath) ? json_decode(file_get_contents($scanPath), true) : null;
$scan = is_file($scanPath) ? json_decode(CheckedFile::read($scanPath), true) : null;
$state = ['target' => $target, 'code_hash' => $code, 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256,
'migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'imported' => $imported,
'publication' => $catalog->publicationState(), 'rows' => $rows, 'sources' => $sources, 'scan' => $scan,
@@ -175,15 +247,27 @@ class EconomicReferenceInstallation
$operations = [];
foreach (self::OPERATIONS as $operation) {
$why = $reasons;
try { $this->authorize($actor, true, $operation); } catch (HttpException $e) { $why[] = $e->getMessage(); }
try {
$this->authorize($actor, true, $operation);
} catch (HttpException $e) {
$why[] = $e->getMessage();
}
$evidence = $this->evidence($target, $code, $operation);
if (! $evidence['ready']) { $why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.'; }
if ($operation !== 'schema' && ! $installed) { $why[] = 'Instale primeiro o esquema nominal.'; }
if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) { $why[] = 'Importação privada completa necessária antes de publicar/retirar.'; }
if (! $evidence['ready']) {
$why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.';
}
if ($operation !== 'schema' && ! $installed) {
$why[] = 'Instale primeiro o esquema nominal.';
}
if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) {
$why[] = 'Importação privada completa necessária antes de publicar/retirar.';
}
if (in_array($operation, ['publish', 'withdraw'], true) && ! DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('action', 'import')->where('after_data->result->manifest_sha256', EconomicReferenceCatalog::MANIFEST_SHA256)->exists()) {
$why[] = 'Execute a importação privada autenticada, inclusive para registos reutilizados.';
}
if ($operation === 'withdraw' && $state['publication'] === 'private') { $why[] = 'O lote ainda não teve publicação para retirar.'; }
if ($operation === 'withdraw' && $state['publication'] === 'private') {
$why[] = 'O lote ainda não teve publicação para retirar.';
}
if ($operation === 'withdraw' && collect($rows)->contains(fn ($r) => $r['status'] === 'reuse'
&& in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id']))) {
$why[] = 'Há referências anteriores já públicas reutilizadas: reconciliar o mapa de retirada antes de agir.';
@@ -195,22 +279,34 @@ class EconomicReferenceInstallation
return [...$state, 'operations' => $operations];
}
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function scan(array $manifest): array
{
$directory = realpath($this->directory()); $records = [];
abort_unless($directory, 422, 'Área privada do lote indisponível.');
$directory = realpath($this->directory());
$records = [];
abort_unless(is_string($directory), 422, 'Área privada do lote indisponível.');
$paths = [['name' => 'manifesto', 'path' => database_path('data/economic-references-lot-01.json'), 'sha256' => EconomicReferenceCatalog::MANIFEST_SHA256]];
foreach ($manifest['sources'] as $source) { $paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']]; }
foreach ($manifest['sources'] as $source) {
$paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']];
}
foreach ($paths as $file) {
abort_unless(is_file($file['path']) && hash_equals($file['sha256'], hash_file('sha256', $file['path'])), 409, 'Originais alterados após prévia.');
abort_unless(is_file($file['path']) && hash_equals($file['sha256'], CheckedFile::sha256($file['path'])), 409, 'Originais alterados após prévia.');
// Scanner's path confinement uses the private source directory; manifest is scanned via a private exact copy.
$path = $file['path'];
if ($file['name'] === 'manifesto') { $path = $directory.'/manifest-scanned.json'; file_put_contents($path, file_get_contents($file['path'])); }
$result = app(MalwareScanner::class)->scan($path, filesize($path), $directory);
if ($result['signatures_at'] instanceof \DateTimeInterface) { $result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM); }
if ($file['name'] === 'manifesto') {
$path = $directory.'/manifest-scanned.json';
file_put_contents($path, file_get_contents($file['path']));
}
$result = app(MalwareScanner::class)->scan($path, CheckedFile::size($path), $directory);
if ($result['signatures_at'] instanceof \DateTimeInterface) {
$result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM);
}
$records[] = ['name' => $file['name'], 'sha256' => $file['sha256'], 'scan' => $result];
abort_unless($result['result'] === 'clean', 423, 'Scan não concluído ou não limpo para '.$file['name'].'. Nenhuma referência foi importada/publicada.');
abort_unless(hash_equals($file['sha256'], hash_file('sha256', $file['path'])) && hash_equals($file['sha256'], hash_file('sha256', $path)), 409, 'Bytes alterados durante o scan.');
abort_unless(CheckedFile::matches($file['path'], $file['sha256']) && CheckedFile::matches($path, $file['sha256']), 409, 'Bytes alterados durante o scan.');
}
$proof = ['at' => now()->toIso8601String(), 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'files' => $records];
file_put_contents($directory.'/scans.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
@@ -218,11 +314,15 @@ class EconomicReferenceInstallation
return $proof;
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, string $operation, array $input): array
{
abort_unless(in_array($operation, self::OPERATIONS, true), 404);
$this->authorize($actor, true, $operation);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']), 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.');
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']) !== [], 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
return DB::transaction(function () use ($actor, $operation, $data) {
@@ -235,44 +335,73 @@ class EconomicReferenceInstallation
return [...$receipt['result'], 'replayed' => true, 'current_publication' => app(EconomicReferenceCatalog::class)->publicationState()];
}
$plan = $this->preview($actor); $phase = $plan['operations'][$operation];
$plan = $this->preview($actor);
$phase = $plan['operations'][$operation];
abort_unless(hash_equals($phase['hash'], $data['hash']), 409, 'Alvo, ator, revisão ou estado alterados; consulte nova prévia.');
abort_unless($phase['ready'], 422, implode(' ', $phase['reasons']));
$manifest = app(EconomicReferenceCatalog::class)->manifest(); $changed = []; $reused = [];
foreach ($this->referenceRows($manifest) as $row) { DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first(); }
$manifest = app(EconomicReferenceCatalog::class)->manifest();
$changed = [];
$reused = [];
foreach ($this->referenceRows($manifest) as $row) {
DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first();
}
DB::table('sectors')->where('id', $manifest['existing']['sector']['id'])->lockForUpdate()->first();
DB::table('economic_clusters')->where('id', $manifest['existing']['cluster']['id'])->lockForUpdate()->first();
// Recheck after locks in case another approved writer changed a reused master.
abort_unless($this->preview($actor)['operations'][$operation]['hash'] === $phase['hash'], 409, 'Referências alteradas durante a preparação da operação.');
if ($operation === 'schema' && ! $plan['installed']) {
config(['economic_references.authenticated_installation' => true]);
try { abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.'); }
finally { config(['economic_references.authenticated_installation' => false]); }
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.');
} finally {
config(['economic_references.authenticated_installation' => false]);
}
abort_unless($this->preview($actor)['installed'], 500, 'Esquema instalado não corresponde ao plano.');
$changed[] = self::MIGRATION;
} elseif ($operation === 'import') {
$scan = $this->scan($manifest);
foreach ($this->referenceRows($manifest) as $i => $row) {
if ($plan['rows'][$i]['status'] === 'reuse') { $reused[] = $row['data']['id']; continue; }
$values = $row['data']; if (isset($values['metadata'])) { $values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR); }
if ($plan['rows'][$i]['status'] === 'reuse') {
$reused[] = $row['data']['id'];
continue;
}
$values = $row['data'];
if (isset($values['metadata'])) {
$values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR);
}
DB::table($row['table'])->insert([...$values, 'created_at' => now(), 'updated_at' => now()]);
$changed[] = $values['id'];
}
} elseif ($operation === 'publish' || $operation === 'withdraw') {
if ($operation === 'publish') { $scan = $this->scan($manifest); }
if ($operation === 'publish') {
$scan = $this->scan($manifest);
}
foreach ($this->referenceRows($manifest) as $row) {
$table = $row['table']; $record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first();
$table = $row['table'];
$record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first();
if (in_array($table, ['reference_catalog_versions', 'economic_activities'], true)) {
if ($operation === 'publish' && $record->status === 'draft') {
DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]); $changed[] = $record->id;
} else { $reused[] = $record->id; }
DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]);
$changed[] = $record->id;
} else {
$reused[] = $record->id;
}
} else {
$visibility = $operation === 'publish' ? 'public' : 'internal';
// Withdrawal touches only objects this importer created, never pre-existing public references.
$createdHere = $this->createdHere($record->id);
if ($operation === 'withdraw' && ! $createdHere) { $reused[] = $record->id; continue; }
if ($record->data_classification !== $visibility) { DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]); $changed[] = $record->id; }
else { $reused[] = $record->id; }
if ($operation === 'withdraw' && ! $createdHere) {
$reused[] = $record->id;
continue;
}
if ($record->data_classification !== $visibility) {
DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]);
$changed[] = $record->id;
} else {
$reused[] = $record->id;
}
}
}
}
@@ -280,7 +409,9 @@ class EconomicReferenceInstallation
'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'code_hash' => $plan['code_hash'], 'replayed' => false,
'publications' => $operation === 'publish' ? 'references only; no investment or original source publication' : 'none', 'business_records_changed' => 0,
'receipt' => $data['idempotency_key'], 'scan' => $scan ?? null];
if ($operation === 'import') { $result['previously_public'] = array_values(array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id')); }
if ($operation === 'import') {
$result['previously_public'] = array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id');
}
DB::table('audit_events')->insert(['source_system' => EconomicReferenceCatalog::SOURCE, 'entity_type' => 'economic-reference-batch', 'entity_id' => $manifest['catalog']['id'],
'actor_user_id' => $actor->id, 'correlation_id' => $data['idempotency_key'], 'action' => $operation, 'data_classification' => 'restricted',
'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
+118 -52
View File
@@ -3,8 +3,10 @@
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -12,75 +14,139 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
/** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class EditorialInstallation
{
public const SOURCE='editorial-schema-installation';
public const MIGRATION='2026_10_18_160000_add_portal_editorial_content';
public const SOURCE = 'editorial-schema-installation';
public const MIGRATION = '2026_10_18_160000_add_portal_editorial_content';
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Http/Controllers/EditorialArticleController.php', 'app/Http/Controllers/PortalManagementController.php', 'app/Http/Controllers/PortalServicesController.php', 'app/Http/Controllers/PublicPortalController.php', 'app/Services/InvestmentCatalogue.php', 'app/Services/LegacyContent.php', 'app/Services/OpportunityClassification.php', 'routes/web.php', 'database/migrations/2026_10_18_160000_add_portal_editorial_content.php', 'resources/js/pages/management/editor.tsx', 'resources/js/pages/portal/article-editor.tsx', 'resources/js/pages/portal/article.tsx', 'resources/js/pages/portal/document.tsx', 'resources/js/pages/portal/manage.tsx', 'app/Services/EditorialInstallation.php', 'resources/views/management/editorial-installation.blade.php', 'resources/views/management/editorial-mfa.blade.php'];
}
public function fingerprint(): string
{
$files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files());
foreach(\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $file){$files[]=['public/build/'.$file->getRelativePathname(),hash_file('sha256',$file->getPathname())];}
usort($files,fn($a,$b)=>strcmp($a[0],$b[0]));
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR));
$files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files());
foreach (File::allFiles(public_path('build')) as $file) {
$files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
}
usort($files, fn ($a, $b) => strcmp($a[0], $b[0]));
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
private function authorize(User $actor,bool $write=false): void
private function authorize(User $actor, bool $write = false): void
{
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'),403,'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);}
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403, 'Gestão autorizada necessária.');
if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
}
private function evidence(array $target,string $code): array
/**
* @param array<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')
&& config('database.connections.pgsql.host')==='127.0.0.1' && !config('database.connections.pgsql.url');
if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];}
if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];}
$path=storage_path('app/private/investmoz-v2/editorial/readiness.json');
$p=is_file($path)?json_decode(file_get_contents($path),true):[];$date=strtotime($p['verified_at'] ?? '');$auth=$p['active_authorization'] ?? [];
$ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'),['127.0.0.1','localhost'],true)
&& ($p['target']['database'] ?? '')===$target['database'] && (int)($p['target']['port'] ?? 0)===(int)$target['port']
&& ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400
$qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test')
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if ($qa) {
return ['ready' => true, 'mode' => 'isolated_QA'];
}
if (app()->environment('testing')) {
return ['ready' => false, 'mode' => 'invalid_QA'];
}
$path = storage_path('app/private/investmoz-v2/editorial/readiness.json');
$p = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
$date = strtotime($p['verified_at'] ?? '');
$auth = $p['active_authorization'] ?? [];
$ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
&& ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port']
&& ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400
&& ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false)
&& ($p['restored_boot_verified'] ?? false) && (int)($p['restore_target']['port'] ?? 0)===55441 && str_ends_with($p['restore_target']['database'] ?? '','_test')
&& ($auth['decision_record'] ?? '')!=='' && ($auth['code_hash'] ?? '')===$code && ($auth['migration'] ?? '')===self::MIGRATION;
foreach(['dump','files'] as $kind){$file=$p[$kind.'_path'] ?? ''; $ready=$ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '',hash_file('sha256',$file));}
return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null];
&& ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test')
&& ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === self::MIGRATION;
foreach (['dump', 'files'] as $kind) {
$file = $p[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file));
}
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);$target=(array)DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');$code=$this->fingerprint();
$ledgerCount=DB::table('migrations')->where('migration',self::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('portal_resources','editorial_content');
$shape=DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='portal_resources' AND column_name='editorial_content'");
$correct=$shape && $shape->data_type==='jsonb' && $shape->is_nullable==='YES';
$consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[];
if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';}
try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();}
$evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';}
$state=['target'=>$target,'migration'=>self::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column,
'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence];
return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))];
$this->authorize($actor);
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$code = $this->fingerprint();
$ledgerCount = DB::table('migrations')->where('migration', self::MIGRATION)->count();
$ledger = $ledgerCount === 1;
$column = Schema::hasColumn('portal_resources', 'editorial_content');
$shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='portal_resources' AND column_name='editorial_content'");
$correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES';
$consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
$reasons = [];
if (! $consistent) {
$reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.';
}
try {
$this->authorize($actor, true);
} catch (HttpException $e) {
$reasons[] = $e->getMessage();
}
$evidence = $this->evidence($target, $code);
if (! $evidence['ready']) {
$reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';
}
$state = ['target' => $target, 'migration' => self::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column,
'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
public function apply(User $actor,array $input): array
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{
$this->authorize($actor,true);
abort_if(array_diff(array_keys($input),['hash','idempotency_key','confirm','_token']),422,'Ator, migration ou caminho arbitrário não permitido.');
$data=Validator::make($input,['hash'=>'required|string|size:64','idempotency_key'=>'required|uuid','confirm'=>'required|accepted'])->validate();
return DB::transaction(function()use($actor,$data){
DB::select('SELECT pg_advisory_xact_lock(181026,160000)');$requestHash=hash('sha256',json_encode($data,JSON_THROW_ON_ERROR));
$old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first();
if($old){$receipt=json_decode($old->after_data,true);abort_unless((int)$old->actor_user_id===$actor->id && hash_equals($receipt['request_hash'],$requestHash),409,'Chave já utilizada com outro ator ou conteúdo.');return [...$receipt['result'],'replayed'=>true];}
$plan=$this->preview($actor);abort_unless(hash_equals($plan['hash'],$data['hash']),409,'Prévia alterada; consulte novamente.');abort_unless($plan['ready'],422,implode(' ',$plan['reasons']));
if(!$plan['installed']){
config(['editorial.authenticated_installation'=>true]);
try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.self::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');}
finally{config(['editorial.authenticated_installation'=>false]);}
$this->authorize($actor, true);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::select('SELECT pg_advisory_xact_lock(181026,160000)');
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if ($old) {
$receipt = json_decode($old->after_data, true);
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.');
return [...$receipt['result'], 'replayed' => true];
}
abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.');
$result=['migration'=>$plan['migration'],'code_hash'=>$plan['code_hash'],'target'=>$plan['target'],'changed'=>!$plan['installed'],'business_records_changed'=>0,'replayed'=>false,'receipt'=>$data['idempotency_key']];
DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'portal-editorial-schema','entity_id'=>null,'actor_user_id'=>$actor->id,
'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]);
$plan = $this->preview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
if (! $plan['installed']) {
config(['editorial.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.');
} finally {
config(['editorial.authenticated_installation' => false]);
}
}
abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.');
$result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'portal-editorial-schema', 'entity_id' => null, 'actor_user_id' => $actor->id,
'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
+20 -7
View File
@@ -2,8 +2,10 @@
namespace App\Services;
use App\Support\CheckedFile;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -11,7 +13,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
/** Reads verified originals without writing a derived edition or changing publication. */
final class EditorialReading
{
public function publicResources()
public function publicResources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
@@ -19,9 +21,12 @@ final class EditorialReading
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
}
public function original(object $resource): ?array
/**
* @return array<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)) {
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'));
}
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)
: ['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);
$content = $override ?? $this->content($resource, $original);
abort_unless(trim($content['body'] ?? '') !== '', 404, 'Este documento não contém um artigo legível.');
$document = DB::table('documents')->find($resource->document_id);
$document = DB::table('documents')->where('id', $resource->document_id)->first();
// The original is verified above; ordinary text adaptations retain the binary gate too.
app(VerifiedDocumentBinary::class)->respond($document);
$mode = $content['image_mode'] ?? (! empty($content['image_resource_id']) ? 'selected' : 'none');
$originalImageUrl = $this->sourceUrl($original['image_source_url'] ?? null, $original['source_url'] ?? null);
$expectedImageSha = $original['image_sha256'] ?? null;
if (! $expectedImageSha && $originalImageUrl === 'https://investemoz.tempo.co.mz/images/tempo-background.png') {
foreach (json_decode(file_get_contents(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $accepted) {
foreach (json_decode(CheckedFile::read(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR) as $accepted) {
if ($accepted['sha256'] === $document->sha256) {
$expectedImageSha = $accepted['original_image_sha256'] ?? null;
break;
+105 -26
View File
@@ -6,6 +6,7 @@ use App\Models\ExistingBusiness;
use App\Models\User;
use App\Policies\ExistingBusinessPolicy;
use App\Services\Mne\BusinessRepository;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
@@ -19,8 +20,12 @@ use Illuminate\Validation\ValidationException;
class ExistingBusinessClassification
{
public const VERSION = 'business-classification-20261016-v1';
private const ACTIONS = ['classification_prepared', 'classification_reviewed', 'classification_disclosed', 'classification_withdrawn'];
/**
* @return array<array-key, mixed>
*/
public function context(ExistingBusiness $business): array
{
$revision = DB::table('existing_business_revisions')->where('business_id', $business->id)->whereIn('action', self::ACTIONS)->orderByDesc('version_no')->first(['snapshot']);
@@ -33,11 +38,18 @@ class ExistingBusinessClassification
return $state;
}
/**
* @param array<array-key, mixed> $row
*/
private function hash(array $row): string
{
return OpportunityCanonicalValue::hash($row);
}
/**
* @param array<array-key, mixed> $row
* @return array<array-key, mixed>|null
*/
private function evidence(array $row, ExistingBusiness $business): ?array
{
$id = $row['classification']['document_id'] ?? null;
@@ -51,6 +63,9 @@ class ExistingBusinessClassification
return $document ? (array) $document : ['unavailable' => $id];
}
/**
* @param array<array-key, mixed> $row
*/
private function evidenceHash(array $row, ExistingBusiness $business): ?string
{
$evidence = $this->evidence($row, $business);
@@ -58,6 +73,9 @@ class ExistingBusinessClassification
return $evidence === null ? null : OpportunityCanonicalValue::hash($evidence);
}
/**
* @param array<array-key, mixed> $rows
*/
private function validateRows(array $rows, ExistingBusiness $business, bool $confirming = false): void
{
$seen = [];
@@ -80,7 +98,8 @@ class ExistingBusinessClassification
if (array_diff(array_keys($row), ['related_id', 'relation_type', 'classification'])) {
$fail('classification', 'Metadados de decisão ou campos desconhecidos não podem ser enviados pelo autor.');
}
$c = $row['classification']; $kind = $c['target_type'];
$c = $row['classification'];
$kind = $c['target_type'];
if (($kind === 'sector' && ! ctype_digit($row['related_id'])) || ($kind !== 'sector' && ! Str::isUuid($row['related_id']))) {
$fail('related_id', 'Identificador de referência inválido.');
}
@@ -91,7 +110,9 @@ class ExistingBusinessClassification
$fail('classification.purpose', 'Regime, admissão e benefício usam a relação regulatória com a zona.');
}
$identity = OpportunityClassification::TARGETS[$kind][0].':'.$row['related_id'].':'.$c['purpose'];
if (isset($seen[$identity])) { $fail('related_id', 'Referência e finalidade já associadas; recupere a relação existente.'); }
if (isset($seen[$identity])) {
$fail('related_id', 'Referência e finalidade já associadas; recupere a relação existente.');
}
$seen[$identity] = true;
if (! empty($c['valid_from']) && ! empty($c['valid_until']) && $c['valid_until'] < $c['valid_from']) {
$fail('classification.valid_until', 'O fim da vigência precede o início.');
@@ -100,13 +121,17 @@ class ExistingBusinessClassification
$fail('classification.authority_id', 'Entidade competente não consta do catálogo publicado.');
}
$evidence = $this->evidence($row, $business);
if ($evidence && isset($evidence['unavailable'])) { $fail('classification.document_id', 'Documento não está associado a este anúncio.'); }
if ($evidence && isset($evidence['unavailable'])) {
$fail('classification.document_id', 'Documento não está associado a este anúncio.');
}
if (($c['external_status'] ?? '') === 'documented_granted' && (empty($c['document_id']) || empty(trim($c['external_act_reference'] ?? '')))) {
$fail('classification.external_act_reference', 'Um ato declarado como concedido exige documento e referência próprios.');
}
if ($confirming) {
foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) {
if (empty(trim($c[$key] ?? ''))) { $fail('classification.'.$key, 'Confirmação exige fundamento e fonte/citação por relação; a preparação pode continuar preliminar.'); }
if (empty(trim($c[$key] ?? ''))) {
$fail('classification.'.$key, 'Confirmação exige fundamento e fonte/citação por relação; a preparação pode continuar preliminar.');
}
}
if ($evidence && ($evidence['status'] !== 'active' || $evidence['deleted_at'] !== null || $evidence['malware_scan_status'] !== 'clean'
|| ! hash_equals(trim($evidence['sha256']), trim((string) $evidence['malware_scanned_sha256']))
@@ -117,13 +142,21 @@ class ExistingBusinessClassification
}
}
/**
* @param array<array-key, mixed> $row
* @param array<array-key, mixed> $state
*/
private function effective(ExistingBusiness $business, array $row, array $state): string
{
$decision = $state['decisions'][$this->hash($row)] ?? null;
if (! $decision) { return $row['classification']['requested_state'] ?? 'preliminary'; }
if ($decision['state'] !== 'confirmed') { return $decision['state']; }
if (! $decision) {
return $row['classification']['requested_state'] ?? 'preliminary';
}
if ($decision['state'] !== 'confirmed') {
return $decision['state'];
}
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
$reviewer = User::find($decision['actor_id']);
$reviewer = User::whereKey($decision['actor_id'])->first();
if (! $reviewer || ! app(ExistingBusinessPolicy::class)->classificationReviewerScope($reviewer, $business, $dimension)
|| (int) ($state['authors'][$this->hash($row)] ?? 0) === (int) $reviewer->id
|| ($decision['evidence_hash'] ?? null) !== $this->evidenceHash($row, $business)) {
@@ -133,13 +166,17 @@ class ExistingBusinessClassification
return 'confirmed';
}
/**
* @return array<array-key, mixed>
*/
public function privateView(User $actor, ExistingBusiness $business): array
{
Gate::forUser($actor)->authorize('view', $business);
$state = $this->context($business);
$rows = [];
foreach ($state['relations'] as $row) {
$hash = $this->hash($row); $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
$hash = $this->hash($row);
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
$rows[] = ['hash' => $hash, 'relation' => $row, 'dimension' => $dimension, 'state' => $this->effective($business, $row, $state),
'can_review' => Gate::forUser($actor)->allows('reviewClassification', $business) && app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension)
&& (int) ($state['authors'][$hash] ?? 0) !== (int) $actor->id,
@@ -158,6 +195,9 @@ class ExistingBusinessClassification
'documents' => $documents->map(fn ($d) => ['id' => $d->id, 'payload' => ['title' => $d->file_name], 'file' => ['file_name' => $d->file_name]])];
}
/**
* @param array<array-key, mixed> $input
*/
public function act(User $actor, string $id, array $input): ExistingBusiness
{
$data = Validator::make($input, [
@@ -167,26 +207,32 @@ class ExistingBusinessClassification
'hash' => ['required_if:action,review', 'string', 'size:64'],
'state' => ['required_if:action,review', Rule::in(['in_validation', 'confirmed', 'rejected', 'archived', 'revoked'])],
], ['reason.required' => 'Indique o fundamento da ação.', 'reason.min' => 'O fundamento deve ter pelo menos 20 caracteres.'])->validate();
abort_if(array_diff(array_keys($input), ['action', 'version_no', 'reason', 'rows', 'hash', 'state']), 422, 'Esta ação aceita apenas campos da classificação.');
abort_if(array_diff(array_keys($input), ['action', 'version_no', 'reason', 'rows', 'hash', 'state']) !== [], 422, 'Esta ação aceita apenas campos da classificação.');
return DB::transaction(function () use ($actor, $id, $data) {
$business = app(BusinessRepository::class)->find($id, true);
$action = $data['action'];
Gate::forUser($actor)->authorize(match ($action) { 'prepare' => 'prepareClassification', 'review' => 'reviewClassification', default => 'discloseClassification' }, $business);
Gate::forUser($actor)->authorize(match ($action) {
'prepare' => 'prepareClassification', 'review' => 'reviewClassification', default => 'discloseClassification'
}, $business);
abort_unless($business->version_no === (int) $data['version_no'], 409, 'A versão mudou. Atualize antes de decidir; preserve os valores preparados.');
$state = $this->context($business);
if ($action === 'prepare') {
$this->validateRows($data['rows'], $business);
$state['relations'] = $data['rows'];
foreach ($data['rows'] as $row) { $state['authors'][$this->hash($row)] ??= $actor->id; }
foreach ($data['rows'] as $row) {
$state['authors'][$this->hash($row)] ??= $actor->id;
}
} elseif ($action === 'review') {
$search = in_array($data['state'], ['revoked', 'archived'], true) ? [...$state['relations'], ...$state['publication']] : $state['relations'];
$row = collect($search)->first(fn ($r) => hash_equals($this->hash($r), $data['hash']));
$row = collect(StructuredData::collectionItems($search))->first(fn ($r) => hash_equals($this->hash($r), $data['hash']));
abort_unless($row, 409, 'Associação inexistente nesta versão.');
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
abort_unless(app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension)
&& (int) ($state['authors'][$data['hash']] ?? 0) !== (int) $actor->id, 403, 'Revisão exige responsabilidade e independência aplicáveis.');
if ($data['state'] === 'confirmed') { $this->validateRows([$row], $business, true); }
if ($data['state'] === 'confirmed') {
$this->validateRows([$row], $business, true);
}
$prior = $state['decisions'][$data['hash']] ?? [];
$decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'],
'evidence_hash' => $this->evidenceHash($row, $business), 'version' => ($prior['version'] ?? 0) + 1];
@@ -216,21 +262,35 @@ class ExistingBusinessClassification
});
}
/**
* @param array<array-key, mixed>|null $preparedState
* @return array<array-key, mixed>
*/
public function publicRows(ExistingBusiness $business, ?array $preparedState = null): array
{
if ($business->trashed() || $business->status !== 'published' || ! $business->published_at || $business->published_at->isFuture()
|| ($business->expires_at && $business->expires_at->isBefore(today()))) { return []; }
$state = $preparedState ?? $this->context($business); $rows = [];
|| ($business->expires_at && $business->expires_at->isBefore(today()))) {
return [];
}
$state = $preparedState ?? $this->context($business);
$rows = [];
foreach ($state['publication'] as $row) {
$c = $row['classification']; $kind = $c['target_type'];
if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) { continue; }
$c = $row['classification'];
$kind = $c['target_type'];
if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) {
continue;
}
$target = app(OpportunityClassification::class)->targets($kind)->where('id', $row['related_id'])->first();
if (! $target || (! empty($c['valid_from']) && $c['valid_from'] > today()->toDateString()) || (! empty($c['valid_until']) && $c['valid_until'] < today()->toDateString())) { continue; }
if (! $target || (! empty($c['valid_from']) && $c['valid_from'] > today()->toDateString()) || (! empty($c['valid_until']) && $c['valid_until'] < today()->toDateString())) {
continue;
}
$effective = $this->effective($business, $row, $state);
if (in_array($effective, ['archived', 'revoked'], true)) { continue; }
if (in_array($effective, ['archived', 'revoked'], true)) {
continue;
}
$rows[] = ['dimension' => OpportunityClassification::TARGETS[$kind][2], 'target_type' => $kind, 'target_id' => (string) $row['related_id'],
'label' => $target->{OpportunityClassification::TARGETS[$kind][1]}, 'target_code' => $target->code ?? null,
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)?->path ?? []) : [], 'purpose' => $c['purpose'], 'purpose_label' => OpportunityClassification::PURPOSES[$c['purpose']],
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)->path ?? []) : [], 'purpose' => $c['purpose'], 'purpose_label' => OpportunityClassification::PURPOSES[$c['purpose']],
'state' => $effective, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null,
'authority_id' => $c['authority_id'] ?? null, 'production_stage' => $c['production_stage'] ?? null,
'external_status' => $c['external_status'], 'valid_from' => $c['valid_from'] ?? null, 'valid_until' => $c['valid_until'] ?? null];
@@ -239,6 +299,9 @@ class ExistingBusinessClassification
return $rows;
}
/**
* @return array<array-key, mixed>
*/
public function filterRules(): array
{
return ['association_type' => ['nullable', Rule::in(array_keys(OpportunityClassification::TARGETS))],
@@ -247,25 +310,41 @@ class ExistingBusinessClassification
'chain_id' => ['nullable', 'uuid'], 'authority_id' => ['nullable', 'uuid'], 'subsector_id' => ['nullable', 'uuid']];
}
/**
* @param array<array-key, mixed> $filters
* @param Builder<ExistingBusiness> $query
*/
public function constrain(Builder $query, array $filters): void
{
$filters = array_filter(array_intersect_key($filters, $this->filterRules()), fn ($v) => $v !== null && $v !== '');
if ($filters === []) { return; }
if ($filters === []) {
return;
}
$ids = [];
(clone $query)->reorder()->select('existing_businesses.*')->chunkById(200, function ($records) use ($filters, &$ids) {
foreach ($records as $business) {
$rows = $this->publicRows($business); $same = array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_purpose', 'association_state']));
$rows = $this->publicRows($business);
$same = array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_purpose', 'association_state']));
$matches = ! $same || collect($rows)->contains(function ($row) use ($same) {
foreach (['association_type' => 'target_type', 'association_target' => 'target_id', 'association_purpose' => 'purpose', 'association_state' => 'state'] as $key => $field) {
if (isset($same[$key]) && $same[$key] !== $row[$field]) { return false; }
if (isset($same[$key]) && $same[$key] !== $row[$field]) {
return false;
}
}
return true;
});
foreach (['chain_id' => 'chain', 'subsector_id' => 'activity'] as $key => $type) {
if (! empty($filters[$key])) { $matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === $type && $r['target_id'] === $filters[$key]); }
if (! empty($filters[$key])) {
$matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === $type && $r['target_id'] === $filters[$key]);
}
}
if (! empty($filters['authority_id'])) {
$matches = $matches && collect($rows)->contains(fn ($r) => ($r['authority_id'] ?? null) === $filters['authority_id']);
}
if ($matches) {
$ids[] = $business->id;
}
if (! empty($filters['authority_id'])) { $matches = $matches && collect($rows)->contains(fn ($r) => ($r['authority_id'] ?? null) === $filters['authority_id']); }
if ($matches) { $ids[] = $business->id; }
}
}, 'existing_businesses.id', 'id');
$query->whereIn('existing_businesses.id', $ids);
@@ -1,4 +1,5 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
@@ -9,28 +10,28 @@ final class ExistingBusinessProfileCategory
// Exact catalogue correspondences plus the explicit mappings already present
// in BusinessWizard and the transaction variants in OpportunityCategoryFields.
public const MAP = [
'company_sale'=>'business_sale', 'participation_sale'=>'business_sale',
'buyer_search'=>'business_sale', 'succession'=>'business_sale',
'franchise'=>'franchise', 'real_estate'=>'real_estate', 'land_right'=>'land_rights',
'investment_project'=>'investment_project', 'expansion'=>'investment_project',
'internationalization'=>'investment_project', 'agricultural_investment'=>'investment_project',
'business_partnership'=>'business_partnership', 'strategic_partner'=>'business_partnership',
'joint_venture'=>'business_partnership', 'capital_increase'=>'financing',
'financial_investor'=>'financing', 'financing_request'=>'financing',
'concession_opportunity'=>'concession', 'supply_opportunity'=>'supply',
'company_sale' => 'business_sale', 'participation_sale' => 'business_sale',
'buyer_search' => 'business_sale', 'succession' => 'business_sale',
'franchise' => 'franchise', 'real_estate' => 'real_estate', 'land_right' => 'land_rights',
'investment_project' => 'investment_project', 'expansion' => 'investment_project',
'internationalization' => 'investment_project', 'agricultural_investment' => 'investment_project',
'business_partnership' => 'business_partnership', 'strategic_partner' => 'business_partnership',
'joint_venture' => 'business_partnership', 'capital_increase' => 'financing',
'financial_investor' => 'financing', 'financing_request' => 'financing',
'concession_opportunity' => 'concession', 'supply_opportunity' => 'supply',
];
// Native form types are separate from the persisted modality. No default to a project.
public const FORM_TYPES = [
'investment_project'=>'investment_project', 'company_sale'=>'company_sale',
'asset_sale'=>'asset_sale', 'business_partnership'=>'business_partnership',
'financing_request'=>'financing_request', 'concession_opportunity'=>'concession_opportunity',
'supply_opportunity'=>'supply_opportunity', 'participation_sale'=>'company_sale',
'asset_lease'=>'asset_sale', 'capital_increase'=>'financing_request',
'financial_investor'=>'financing_request', 'strategic_partner'=>'business_partnership',
'joint_venture'=>'business_partnership', 'expansion'=>'investment_project',
'agricultural_investment'=>'investment_project', 'internationalization'=>'investment_project',
'buyer_search'=>'company_sale', 'succession'=>'company_sale',
'investment_project' => 'investment_project', 'company_sale' => 'company_sale',
'asset_sale' => 'asset_sale', 'business_partnership' => 'business_partnership',
'financing_request' => 'financing_request', 'concession_opportunity' => 'concession_opportunity',
'supply_opportunity' => 'supply_opportunity', 'participation_sale' => 'company_sale',
'asset_lease' => 'asset_sale', 'capital_increase' => 'financing_request',
'financial_investor' => 'financing_request', 'strategic_partner' => 'business_partnership',
'joint_venture' => 'business_partnership', 'expansion' => 'investment_project',
'agricultural_investment' => 'investment_project', 'internationalization' => 'investment_project',
'buyer_search' => 'company_sale', 'succession' => 'company_sale',
];
public static function category(ExistingBusiness $business): ?string
+21 -1
View File
@@ -38,6 +38,9 @@ final class FintechInvestment
public function __construct(private readonly VirtualDesk $desk) {}
/**
* @param array<array-key, mixed> $input
*/
public function save(User $actor, string $id, array $input): void
{
$keys = implode(',', array_keys(self::FIELDS));
@@ -89,6 +92,9 @@ final class FintechInvestment
});
}
/**
* @param array<array-key, mixed> $value
*/
private function fingerprint(array $value): string
{
$canonical = function ($v) use (&$canonical) {
@@ -105,6 +111,10 @@ final class FintechInvestment
return hash('sha256', json_encode($canonical($value), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
/**
* @param array<array-key, mixed> $record
* @return array<array-key, mixed>
*/
public function reviewFingerprints(array $record): array
{
$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
{
$record = $payload['investor_path']['fintech'] ?? null;
@@ -145,6 +160,11 @@ final class FintechInvestment
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
{
$record = $payload['investor_path']['fintech'] ?? null;
@@ -174,7 +194,7 @@ final class FintechInvestment
return ['guidance' => $guidance, 'guidance_version' => InvestorGuidanceRules::VERSION, 'schema' => self::VERSION, 'record' => $record, 'fields' => self::FIELDS, 'activities' => self::ACTIVITIES, 'countries' => app(CountryCatalogue::class)->all(),
'requirements' => $requirements, 'documents' => $docs, 'review_fingerprints' => $record ? $this->reviewFingerprints($record) : [],
'affected_reviews' => array_values(array_column(array_filter($steps,fn ($s) => $s['fintech_review_required'] ?? false), 'code')),
'affected_reviews' => array_column(array_filter($steps, fn ($s) => $s['fintech_review_required'] ?? false), 'code'),
'limits' => ['Fintech é caracterização do investimento, não nova categoria jurídica ou autorização.', 'Preparação de orçamento, tecnologia, documentos e parceiros pode avançar em paralelo.', 'Países de operação não alteram país mestre, origem, nacionalidade ou residências.', 'Capitais mínimos, taxas, prazos e licenças específicos permanecem por determinar sem disposição e período aplicáveis.']];
}
}
+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) {
$locked = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
abort_unless(app(ScopedPortalAdministration::class)->interest($actor,$locked),403);
abort_unless(app(ScopedPortalAdministration::class)->interest($actor, $locked), 403);
$from = $locked->status;
$actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null;
$followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction
+11 -4
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
@@ -239,7 +240,8 @@ class InvestmentCatalogue
};
}
private function declaredProvince($query, string $name): void
/** @param Builder<Opportunity> $query */
private function declaredProvince(Builder $query, string $name): void
{
// Read the published province declaration only when no more precise territory contradicts it.
// This does not assign any canonical ID, district, post, locality or coordinates to the record.
@@ -249,7 +251,8 @@ class InvestmentCatalogue
->whereRaw('lower(btrim(opportunities.province)) = ?', [mb_strtolower(trim($name))]));
}
private function approvedCorridorProvince($query, string $provinceId): void
/** @param Builder<Opportunity> $query */
private function approvedCorridorProvince(Builder $query, string $provinceId): void
{
$code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code');
$names = [];
@@ -344,8 +347,8 @@ class InvestmentCatalogue
// A chain's documented scope comes from its public cluster, never
// from the opportunities surviving the current search.
$ids = app(OpportunityClassification::class)->targets('chain')
->whereIn('cluster_id', $result['clusters']->pluck('id'))->pluck('id');
$result['classification']['targets']['chain'] = collect($result['classification']['targets']['chain'])->whereIn('id', $ids)->values();
->whereIn('cluster_id', ($result['clusters'] ?? collect())->pluck('id'))->pluck('id');
$result['classification']['targets']['chain'] = collect(StructuredData::collectionItems($result['classification']['targets']['chain']))->whereIn('id', $ids)->values();
}
// Activities are a public classification catalogue, not evidence of
// investment availability in a territory or sector.
@@ -353,6 +356,10 @@ class InvestmentCatalogue
return $result;
}
/**
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
*/
private function publicAssociatedIds(array $filters): array
{
$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\Models\Opportunity;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Resolve documented complements without creating opportunities or master relations. */
final class InvestmentComplements
@@ -23,6 +25,9 @@ final class InvestmentComplements
'finance' => ['Financiamento', 'financiamento|serviços financeiros'],
];
/**
* @return array<array-key, mixed>
*/
public function options(string $origin, ?string $territory = null): array
{
[$originKind, $originId] = array_pad(explode(':', $origin, 2), 2, '');
@@ -30,18 +35,24 @@ final class InvestmentComplements
$principal = null;
$relationEvidence = [];
if (str_starts_with($origin, 'portfolio:')) {
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail(substr($origin, 10));
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->whereKey(substr($origin, 10))->firstOrFail();
$text = trim(($record->content['focus'] ?? '')."\n".($record->content['gaps'] ?? '')."\n".($record->content['description'] ?? ''));
$reference = ['key' => $origin, 'title' => $record->title, 'href' => '/carteira/'.$record->id, 'version' => $record->version_no];
$ids = app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($record->$foreign) $ids = [...$ids, ...DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->pluck('opportunity_id')->all()];
if ($record->$foreign) {
$ids = [...$ids, ...DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->pluck('opportunity_id')->all()];
}
}
$basis = $record->source;
foreach (array_unique($ids) as $id) $relationEvidence[$id] = ['type' => 'portfolio', 'origin' => $origin, 'source' => $basis];
foreach (array_unique($ids) as $id) {
$relationEvidence[$id] = ['type' => 'portfolio', 'origin' => $origin, 'source' => $basis];
}
$serviceOrigins = [['dossier', $record->id]];
foreach (['project_id' => 'project', 'cluster_id' => 'cluster'] as $foreign => $type) {
if ($record->$foreign) $serviceOrigins[] = [$type, $record->$foreign];
if ($record->$foreign) {
$serviceOrigins[] = [$type, $record->$foreign];
}
}
} else {
$reference = app(InvestorContext::class)->reference($origin);
@@ -49,8 +60,10 @@ final class InvestmentComplements
// The context resolver has already checked the actor's private scope.
// A private preparation is valid dossier input, not a public offer
// from which this public-complement catalogue can infer services.
if (($reference['visibility'] ?? 'public') === 'private') return [];
$principal = Opportunity::published()->findOrFail($reference['id']);
if (($reference['visibility'] ?? 'public') === 'private') {
return [];
}
$principal = Opportunity::published()->whereKey($reference['id'])->firstOrFail();
$text = $principal->summary;
$basis = 'Resumo cadastrado da oportunidade '.$principal->reference;
$projects = DB::table('opportunity_projects')->where('opportunity_id', $principal->id)->pluck('project_id');
@@ -59,18 +72,24 @@ final class InvestmentComplements
$targetProjects = DB::table('opportunity_projects')->whereIn('project_id', $links->pluck('complementary_project_id'))->get();
$ids = [$principal->id, ...$targetProjects->pluck('opportunity_id')->all()];
$relationEvidence[$principal->id] = ['type' => 'same_fiche', 'source' => $basis];
foreach ($targetProjects as $targetProject) $relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project',
'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn($link) => (array)$link)->all()];
foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) $text .= "\n".$linked->summary;
if (count($ids) > 1) $basis .= '; relações complementares públicas entre projectos';
foreach ($targetProjects as $targetProject) {
$relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project',
'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn ($link) => (array) $link)->all()];
}
foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) {
$text .= "\n".$linked->summary;
}
if (count($ids) > 1) {
$basis .= '; relações complementares públicas entre projectos';
}
$serviceOrigins = [['investment', (string) $principal->id]];
$classification = app(OpportunityClassification::class);
$principalChains = collect($classification->publicRows($principal))->where('target_type', 'chain')->filter(fn($r) => !empty($r['source_reference']));
$principalChains = collect($classification->publicRows($principal))->where('target_type', 'chain')->filter(fn ($r) => ! empty($r['source_reference']));
$chains = $principalChains->pluck('target_id')->all();
if ($chains) {
Opportunity::published()->whereNot('id', $principal->id)->chunkById(100, function ($records) use (&$ids, &$relationEvidence, $chains, $principalChains, $classification) {
foreach ($records as $candidate) {
$related = collect($classification->publicRows($candidate))->where('target_type', 'chain')->filter(fn($r) => !empty($r['source_reference']));
$related = collect($classification->publicRows($candidate))->where('target_type', 'chain')->filter(fn ($r) => ! empty($r['source_reference']));
$shared = array_intersect($chains, $related->pluck('target_id')->all());
if ($shared) {
$ids[] = $candidate->id;
@@ -84,15 +103,25 @@ final class InvestmentComplements
$candidates = Opportunity::published()->whereIn('id', array_unique($ids))->get();
$items = [];
foreach (self::TERMS as $code => [$label, $pattern]) {
if (!preg_match('/\b(?:'.$pattern.')\b/iu', $text, $match)) continue;
if (! preg_match('/\b(?:'.$pattern.')\b/iu', $text, $match)) {
continue;
}
$offers = [];
foreach ($candidates as $candidate) {
// A common corridor alone does not prove the component. Keep the supporting excerpt.
if (!preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title."\n".$candidate->summary, $evidence)) continue;
if (! preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title."\n".$candidate->summary, $evidence)) {
continue;
}
// Mentioning a need for funding/training in a project's summary is not an offer.
if (in_array($code, ['certification', 'training', 'finance'], true) && !preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title)) continue;
if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) continue;
if ($territory && $candidate->primary_territory_id && !$this->compatible($territory, $candidate->primary_territory_id)) continue;
if (in_array($code, ['certification', 'training', 'finance'], true) && ! preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title)) {
continue;
}
if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) {
continue;
}
if ($territory && $candidate->primary_territory_id && ! $this->compatible($territory, $candidate->primary_territory_id)) {
continue;
}
$offers[] = ['key' => 'opportunity:'.$candidate->id, 'title' => $candidate->title, 'href' => '/oportunidades/'.$candidate->slug,
'relationship' => $relationEvidence[$candidate->id] ?? null,
'version' => $candidate->management_version, 'evidence' => $evidence[0], 'location' => ($principal && $principal->id === $candidate->id ? 'Componente referido na própria ficha. ' : '').($candidate->primary_territory_id ? app(InvestorContext::class)->reference('territory:'.$candidate->primary_territory_id)['title'] : 'Implantação por definir')];
@@ -100,62 +129,80 @@ final class InvestmentComplements
// Services are concrete published offerings explicitly associated with this source.
$services = [];
if (in_array($code, ['certification', 'training', 'finance'], true) && Schema::hasTable('partnership_origins') && Schema::hasTable('partnership_interests')) {
if ($serviceOrigins) {
$services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id')
->where(function ($q) use ($serviceOrigins) {
foreach ($serviceOrigins as [$type, $id]) $q->orWhere(fn($q) => $q->where('o.type', $type)->where('o.target_id', $id));
})->where('p.status', 'published')->where('p.kind', 'offering')
->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training')
->when($code !== 'finance', fn($q) => $q->where(fn($q) => $q->where('p.title', 'ilike', '%'.$match[0].'%')->orWhereRaw("p.payload->>'summary' ILIKE ?", ['%'.$match[0].'%'])))->distinct()->get(['p.id', 'p.title'])
->filter(function ($p) use ($territory) {
if (!$territory) return true;
$locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id');
return $locations->isEmpty() || $locations->contains(fn($id) => $this->compatible($territory, $id));
})->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all();
}
$services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id')
->where(function ($q) use ($serviceOrigins) {
foreach ($serviceOrigins as [$type, $id]) {
$q->orWhere(fn ($q) => $q->where('o.type', $type)->where('o.target_id', $id));
}
})->where('p.status', 'published')->where('p.kind', 'offering')
->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training')
->when($code !== 'finance', fn ($q) => $q->where(fn ($q) => $q->where('p.title', 'ilike', '%'.$match[0].'%')->orWhereRaw("p.payload->>'summary' ILIKE ?", ['%'.$match[0].'%'])))->distinct()->get(['p.id', 'p.title'])
->filter(function ($p) use ($territory) {
if (! $territory) {
return true;
}
$locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id');
return $locations->isEmpty() || $locations->contains(fn ($id) => $this->compatible($territory, $id));
})->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all();
}
$excerpt = collect(explode("\n", $text))->first(fn($line) => preg_match('/\b(?:'.$pattern.')\b/iu', $line));
$excerpt = collect(explode("\n", $text))->first(fn ($line) => preg_match('/\b(?:'.$pattern.')\b/iu', $line) === 1);
$items[] = ['code' => $code, 'title' => $label, 'origin' => $reference, 'territory_id' => $territory, 'territory' => $territoryReference,
'source' => $basis, 'excerpt' => $excerpt, 'source_sha256' => hash('sha256', $text), 'opportunities' => $offers, 'services' => $services];
}
return $items;
}
private function compatible(string $a, string $b): bool
{
if ($a === $b) return true;
if ($a === $b) {
return true;
}
$geo = app(InvestmentGeography::class);
return in_array($b, $geo->descendants($a), true) || in_array($a, $geo->descendants($b), true);
}
/**
* @param array<array-key, mixed> $input
* @param array<array-key, mixed> $previous
* @return array<array-key, mixed>
*/
public function resolve(array $input, array $previous = []): array
{
$resolved = [];
foreach ($input as $choice) {
$target = $choice['target'] ?? null;
$old = collect($previous)->first(fn($r) => $r['origin']['key'] === $choice['origin'] && $r['code'] === $choice['code'] && ($r['target']['key'] ?? null) === $target && ($r['territory_id'] ?? null) === ($choice['territory_id'] ?? null));
$old = collect($previous)->first(fn ($r) => $r['origin']['key'] === $choice['origin'] && $r['code'] === $choice['code'] && ($r['target']['key'] ?? null) === $target && ($r['territory_id'] ?? null) === ($choice['territory_id'] ?? null));
try {
$item = collect($this->options($choice['origin'], $choice['territory_id'] ?? null))->firstWhere('code', $choice['code']);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
if (!$old) throw $e;
} catch (ModelNotFoundException $e) {
if (! $old) {
throw $e;
}
$item = null;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
if (!$old || $e->getStatusCode() !== 404) throw $e;
} catch (HttpException $e) {
if (! $old || $e->getStatusCode() !== 404) {
throw $e;
}
$item = null;
}
$offer = $target && $item ? collect([...$item['opportunities'], ...$item['services']])->firstWhere('key', $target) : null;
if ($old && (!$item || ($target && !$offer) || (!$target && ($item['opportunities'] || $item['services'])))) {
if ($old && (! $item || ($target && ! $offer) || (! $target && ($item['opportunities'] || $item['services'])))) {
$resolved[$old['key']] = [...$old, 'availability' => 'review_required'];
continue;
}
abort_unless($item, 422, 'Complemento sem referência documentada neste investimento.');
abort_if($target && !$offer, 422, 'A oferta complementar deixou de estar disponível neste contexto.');
abort_if(!$target && ($item['opportunities'] || $item['services']), 422, 'Já existe uma oferta relacionada; seleccione a ficha correspondente.');
abort_if($target && ! $offer, 422, 'A oferta complementar deixou de estar disponível neste contexto.');
abort_if(! $target && ($item['opportunities'] || $item['services']), 422, 'Já existe uma oferta relacionada; seleccione a ficha correspondente.');
$key = hash('sha256', $choice['origin'].'|'.$choice['code'].'|'.($target ?? 'idea').'|'.($choice['territory_id'] ?? ''));
$resolved[$key] = ['key' => $key, 'origin' => $item['origin'], 'code' => $item['code'], 'title' => $item['title'],
'kind' => $target ? (str_starts_with($target, 'opportunity:') ? 'opportunity' : 'service') : 'need',
'target' => $offer, 'territory_id' => $item['territory_id'], 'territory' => $item['territory'], 'source' => $item['source'], 'excerpt' => $item['excerpt'], 'source_sha256' => $item['source_sha256']];
}
return array_values($resolved);
}
}
+58 -18
View File
@@ -3,83 +3,123 @@
namespace App\Services;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Support\Arr;
/** An identified opportunity within the authorised dossier, never the account's last choice. */
final class InvestmentConsumerScope
{
/**
* @param array<array-key, mixed> $context
*/
public function key(array $context, ?string $key, bool $required = false): ?string
{
$choices = $context['opportunity_choices'] ?? [];
$key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null);
abort_if($key && !collect($choices)->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.');
abort_if($required && count($choices) > 1 && !$key, 422, 'Escolha a oportunidade em tratamento.');
abort_if($key && ! collect(StructuredData::collectionItems($choices))->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.');
abort_if($required && count($choices) > 1 && ! $key, 422, 'Escolha a oportunidade em tratamento.');
return $key;
}
/**
* @param array<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
{
$key = $this->key($context, $key);
$path = $payload['investor_path'] ?? [];
$defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown'];
// A dossier-wide dispensation cannot decide a distinct opportunity's needs.
if ($key && count($context['opportunity_choices'] ?? []) > 1) return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])];
if ($key && isset($path['opportunity_facts'][$key])) return [...$defaults, ...$path['opportunity_facts'][$key]];
if (count($context['opportunity_choices'] ?? []) > 1) return $defaults;
if ($key && count($context['opportunity_choices'] ?? []) > 1) {
return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])];
}
if ($key && isset($path['opportunity_facts'][$key])) {
return [...$defaults, ...$path['opportunity_facts'][$key]];
}
if (count($context['opportunity_choices'] ?? []) > 1) {
return $defaults;
}
return [...$defaults, ...($path['facts'] ?? [])];
}
/**
* @param array<array-key, mixed> $context
* @return array<array-key, mixed>
*/
public function context(array $context, ?string $key): array
{
$choices = $context['opportunity_choices'] ?? [];
if (!$choices) return $context;
if (! $choices) {
return $context;
}
$key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null);
$choice = $key ? collect($choices)->firstWhere('opportunity.key', $key) : null;
abort_if($key && !$choice, 422, 'Escolha uma oportunidade deste dossier.');
$choice = $key ? collect(StructuredData::collectionItems($choices))->firstWhere('opportunity.key', $key) : null;
abort_if($key && ! $choice, 422, 'Escolha uma oportunidade deste dossier.');
$specific = ['project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'special_zone_id', 'investment_regime'];
$context['fields'] = Arr::except($context['fields'], $specific);
$context['provenance'] = Arr::except($context['provenance'] ?? [], $specific);
$context['selection'] = Arr::except($context['selection'], ['opportunity', 'territory', 'park']);
if (!$choice) {
if (! $choice) {
$context['opportunity_choices'] = [];
$context['requirements_assessment']['requirements'] = [];
return $context;
}
$context['selection']['opportunity'] = $choice['opportunity'];
$context['fields'] = [...$context['fields'], ...Arr::only($choice['opportunity']['fields'], $specific)];
foreach (Arr::only($choice['opportunity']['fields'], $specific) as $field => $value) $context['provenance'][$field] = Arr::only($choice['opportunity'], ['key','kind','id','title','version','sha256']);
foreach (Arr::only($choice['opportunity']['fields'], $specific) as $field => $value) {
$context['provenance'][$field] = Arr::only($choice['opportunity'], ['key', 'kind', 'id', 'title', 'version', 'sha256']);
}
unset($context['selection']['sector']);
if (!empty($context['fields']['sector_id'])) $context['selection']['sector'] = app(InvestorContext::class)->reference('sector:'.$context['fields']['sector_id']);
if (! empty($context['fields']['sector_id'])) {
$context['selection']['sector'] = app(InvestorContext::class)->reference('sector:'.$context['fields']['sector_id']);
}
if ($choice['territory']) {
$context['selection']['territory'] = $choice['territory'];
$context['fields'] = [...Arr::except($context['fields'], array_keys(InvestmentGeography::FIELDS)), ...$choice['territory']['fields']];
foreach ($choice['territory']['fields'] as $field => $value) $context['provenance'][$field] = Arr::only($choice['territory'], ['key','kind','id','title','version','sha256']);
foreach ($choice['territory']['fields'] as $field => $value) {
$context['provenance'][$field] = Arr::only($choice['territory'], ['key', 'kind', 'id', 'title', 'version', 'sha256']);
}
} else {
$context['fields'] = Arr::except($context['fields'], ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location']);
}
$park = $choice['park'] ?? null;
if ($park) $context['selection']['park'] = $park;
if ($park) {
$context['selection']['park'] = $park;
}
$context['fields']['special_zone_id'] = $park['id'] ?? '';
$context['fields']['investment_regime'] = $park['fields']['investment_regime'] ?? 'general';
$context['opportunity_choices'] = [$choice];
$context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn($r) => $r['opportunity_key'] === $key));
$context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn ($r) => $r['opportunity_key'] === $key));
$context['consumer_opportunity'] = $choice;
$context['complements'] = array_values(array_filter($context['complements'] ?? [], fn($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '',10),array_column($choice['portfolio_origins'] ?? [],'id'),true)));
$context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices'=>[$choice], 'fields'=>Arr::except($context['fields'],['project_title','project_summary']), 'complements'=>$context['complements']]);
$context['complements'] = array_values(array_filter($context['complements'] ?? [], fn ($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '', 10), array_column($choice['portfolio_origins'] ?? [], 'id'), true)));
$context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices' => [$choice], 'fields' => Arr::except($context['fields'], ['project_title', 'project_summary']), 'complements' => $context['complements']]);
return $context;
}
/**
* @param array<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
public function bind(User $actor, string $id, array $payload, bool $required = false): array
{
$case = app(VirtualDesk::class)->case($actor, $id, true);
$context = app(InvestorContext::class)->state($case);
$scoped = $this->context($context, $payload['context_opportunity'] ?? null);
$choice = $scoped['consumer_opportunity'] ?? null;
abort_if($required && count($context['opportunity_choices'] ?? []) > 1 && !$choice, 422, 'Escolha a oportunidade em tratamento antes de submeter.');
if (!$choice) return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
abort_if($required && count($context['opportunity_choices'] ?? []) > 1 && ! $choice, 422, 'Escolha a oportunidade em tratamento antes de submeter.');
if (! $choice) {
return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
}
// Canonical server snapshot; client text cannot establish a different origin or territory.
$payload['context_opportunity'] = $choice['opportunity']['key'];
$payload['context_opportunity_snapshot'] = json_encode(['context_id' => $id, 'revision' => $context['revision'], 'choice' => $choice, 'requirements' => $scoped['requirements_assessment'] ?? null], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
return $payload;
}
}
+15 -6
View File
@@ -7,6 +7,10 @@ final class InvestmentContextRequirements
{
public const VERSION = 'context-requirements-20261023';
/**
* @param array<array-key, mixed> $context
* @return array<array-key, mixed>
*/
public function assess(array $context): array
{
$rows = [];
@@ -32,16 +36,21 @@ final class InvestmentContextRequirements
['Actividades efectivas e modalidade', 'Requerimento e anexos da modalidade aplicável; acto quando emitido'],
'Identificar as actividades efectivas antes de seleccionar o procedimento. O nome Agronegócio não escolhe uma licença única.', 'https://www.bau.gov.mz/nossos-servicos/');
foreach (['processing' => ['Licenciamento industrial', 'Decreto 22/2014'], 'trade' => ['Licenciamento comercial', 'Decreto 34/2013']] as $activity => [$title, $basis]) {
if (in_array($activity, $activities, true)) $add($activity, $title, 'conditional', 'S08', 'BAU, IP / entidade licenciadora da modalidade',
['Caracterização do estabelecimento e actividade', 'Documentos e regime aplicáveis, incluindo simplificado ou mera comunicação quando preenchidas as suas condições'],
$basis.'; regimes 39/2017 e 27/2023 no catálogo oficial BAU. Dimensão e modalidade ainda devem ser especificadas; não se selecciona automaticamente o regime.', 'https://www.bau.gov.mz/legislacao/');
if (in_array($activity, $activities, true)) {
$add($activity, $title, 'conditional', 'S08', 'BAU, IP / entidade licenciadora da modalidade',
['Caracterização do estabelecimento e actividade', 'Documentos e regime aplicáveis, incluindo simplificado ou mera comunicação quando preenchidas as suas condições'],
$basis.'; regimes 39/2017 e 27/2023 no catálogo oficial BAU. Dimensão e modalidade ainda devem ser especificadas; não se selecciona automaticamente o regime.', 'https://www.bau.gov.mz/legislacao/');
}
}
foreach (['water' => 'Uso de água', 'forestry' => 'Actividade florestal', 'animal' => 'Actividade pecuária', 'transport' => 'Transporte e logística'] as $activity => $title) {
if (in_array($activity, $activities, true)) $add($activity, $title, 'undetermined', 'S08', 'Autoridade sectorial competente a identificar para a modalidade declarada',
['Modalidade e dimensão da actividade', 'Fonte e documentação sectorial específica a associar ao dossier'],
'Actividade declarada; a referência do lote não contém o procedimento específico. Não se considera dispensada ou autorizada.', 'https://www.apiex.gov.mz/wp-content/uploads/2024/01/Mozambique-Business-Licensing-Guide.pdf');
if (in_array($activity, $activities, true)) {
$add($activity, $title, 'undetermined', 'S08', 'Autoridade sectorial competente a identificar para a modalidade declarada',
['Modalidade e dimensão da actividade', 'Fonte e documentação sectorial específica a associar ao dossier'],
'Actividade declarada; a referência do lote não contém o procedimento específico. Não se considera dispensada ou autorizada.', 'https://www.apiex.gov.mz/wp-content/uploads/2024/01/Mozambique-Business-Licensing-Guide.pdf');
}
}
}
return ['version' => self::VERSION, 'requirements' => $rows,
'fiscal_notice' => 'Localização, corredor, cluster ou projecto âncora não activam benefícios fiscais. O simulador mantém as condições, fonte, período e âmbito próprios; condições ausentes permanecem indeterminadas.'];
}
+46 -15
View File
@@ -10,40 +10,65 @@ final class InvestmentCorridorContext
public function key(Opportunity $record): ?string
{
$name = data_get($record->workflow_context, 'corridor_agribusiness.corridor');
foreach (config('investment_corridors') as $key => $entry) if ($entry['name'] === $name) return $key;
foreach (config('investment_corridors') as $key => $entry) {
if ($entry['name'] === $name) {
return $key;
}
}
return null;
}
/** Reuse Tempo's approved provincial context; never infer a district or master link. */
/** Reuse Tempo's approved provincial context; never infer a district or master link.
* @return array<array-key, mixed>
*/
public function catalogueProvinces(Opportunity $record): array
{
if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) return [];
if ($record->primary_territory_id || $record->additionalTerritories()->exists()) return [];
if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) {
return [];
}
if ($record->primary_territory_id || $record->additionalTerritories()->exists()) {
return [];
}
$key = $this->key($record);
return $key ? $this->provinces($key) : [];
}
/**
* @return array<array-key, mixed>
*/
public function provinces(string $key): array
{
$entry = config('investment_corridors.'.$key);
abort_unless(is_array($entry), 422, 'Seleccione um corredor disponível.');
$records = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')
->whereIn('source_code', $entry['provinces'])->get(['id', 'name', 'parent_id', 'location_type', 'source_code'])->all();
abort_unless(count($records) === count($entry['provinces']), 503, 'Não foi possível recuperar todas as províncias deste corredor. Tente novamente.');
if (! (count($records) === count($entry['provinces']))) {
abort(503, 'Não foi possível recuperar todas as províncias deste corredor. Tente novamente.');
}
return $records;
}
public function compatible(string $key, ?string $territory): bool
{
if (!$territory) return true;
if (! $territory) {
return true;
}
$path = app(InvestmentGeography::class)->path($territory);
return $path !== [] && in_array($path[0]->id, array_column($this->provinces($key), 'id'), true);
}
/** Both membership in the selected area and documented public zone coverage are required. */
/** Both membership in the selected area and documented public zone coverage are required.
* @return array<array-key, mixed>
*/
public function parks(?string $key, ?string $territory): array
{
if (!$key || !$territory) return [];
if (! $key || ! $territory) {
return [];
}
abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.');
$selectedPath = array_column(app(InvestmentGeography::class)->path($territory), 'id');
$candidates = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get();
@@ -51,18 +76,24 @@ final class InvestmentCorridorContext
foreach ($candidates as $zone) {
$ids = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->where('validation_status', 'validated')
->whereIn('scope_type', ['legal_scope', 'administrative', 'site'])
->where(fn($q)=>$q->whereNull('valid_from')->orWhereDate('valid_from','<=',today()))
->where(fn($q)=>$q->whereNull('valid_until')->orWhereDate('valid_until','>=',today()))->get(['geo_location_id','scope_type'])->all();
if ($zone->primary_territory_id) $ids[] = (object)['geo_location_id'=>$zone->primary_territory_id,'scope_type'=>'site'];
->where(fn ($q) => $q->whereNull('valid_from')->orWhereDate('valid_from', '<=', today()))
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()))->get(['geo_location_id', 'scope_type'])->all();
if ($zone->primary_territory_id) {
$ids[] = (object) ['geo_location_id' => $zone->primary_territory_id, 'scope_type' => 'site'];
}
foreach ($ids as $link) {
$id=$link->geo_location_id;
if (!$this->compatible($key, $id)) continue;
$id = $link->geo_location_id;
if (! $this->compatible($key, $id)) {
continue;
}
$path = array_column(app(InvestmentGeography::class)->path($id), 'id');
if (in_array($territory, $path, true) || ($link->scope_type==='legal_scope' && in_array($id, $selectedPath, true))) {
$result[] = $zone->only(['id', 'name', 'category', 'version_no']); break;
if (in_array($territory, $path, true) || ($link->scope_type === 'legal_scope' && in_array($id, $selectedPath, true))) {
$result[] = $zone->only(['id', 'name', 'category', 'version_no']);
break;
}
}
}
return $result;
}
}
+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'];
/** 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
{
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\Models\Opportunity;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
/** Multiple identified references in the existing encrypted investment context. */
final class InvestmentOpportunityChoices
{
/**
* @param array<array-key, mixed> $ids
* @return array<array-key, mixed>
*/
public function portfolioOrigins(array $ids, int $opportunity): array
{
$origins = [];
foreach (array_unique($ids) as $id) {
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail($id);
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->whereKey($id)->firstOrFail();
$kind = null;
if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) $kind = 'corridor';
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) $kind = rtrim($relation, 's');
if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) {
$kind = 'corridor';
}
abort_unless($kind, 422, 'A associação escolhida não pertence a esta oportunidade.');
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) {
$kind = rtrim($relation, 's');
}
}
abort_unless($kind !== null, 422, 'A associação escolhida não pertence a esta oportunidade.');
$origins[] = ['id' => $record->id, 'title' => $record->title, 'kind' => $kind, 'href' => '/carteira/'.$record->id,
'version' => $record->version_no, 'sha256' => hash('sha256', json_encode([$record->title, $record->source, $record->content], JSON_THROW_ON_ERROR))];
}
return $origins;
}
/** Add explicitly consulted associations without resetting saved geography or deliberate answers. */
/** Add explicitly consulted associations without resetting saved geography or deliberate answers.
* @param array<array-key, mixed> $entries
* @param array<array-key, mixed> $saved
* @return array<array-key, mixed>
*/
public function mergeEntries(array $saved, array $entries): array
{
foreach ($entries as $entry) {
$index = array_search($entry['opportunity']['key'], array_column(array_column($saved, 'opportunity'), 'key'), true);
if ($index === false) { $saved[] = $entry; continue; }
if ($index === false) {
$saved[] = $entry;
continue;
}
$origins = collect([...($saved[$index]['portfolio_origins'] ?? []), ...($entry['portfolio_origins'] ?? [])])->unique('id')->values()->all();
if ($origins) $saved[$index]['portfolio_origins'] = $origins;
if ($origins) {
$saved[$index]['portfolio_origins'] = $origins;
}
}
return $saved;
}
/**
* @param array<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
{
$result = [];
foreach ($choices as $choice) {
$ref = app(InvestorContext::class)->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422, 'Seleccione uma ficha de oportunidade.');
$record = Opportunity::findOrFail($ref['id']);
$record = Opportunity::whereKey($ref['id'])->firstOrFail();
$old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']);
$origins = $this->portfolioOrigins($choice['portfolio_origins'] ?? array_column($old['portfolio_origins'] ?? [], 'id'), (int) $record->id);
$corridors = app(InvestmentCorridorContext::class);
@@ -52,14 +78,18 @@ final class InvestmentOpportunityChoices
$provinces = $corridor ? $corridors->provinces($corridor) : [];
$documented = $record->primary_territory_id;
$territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null);
if (!$territory && count($provinces) === 1) $territory = 'territory:'.$provinces[0]->id;
if ($corridor && $territory) abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.');
if (! $territory && count($provinces) === 1) {
$territory = 'territory:'.$provinces[0]->id;
}
if ($corridor && $territory) {
abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.');
}
$previousTerritory = $old['territory']['key'] ?? null;
$newAlternative = $documented && $territory !== 'territory:'.$documented
&& (! $old || ($old['documented_territory_id'] ?? null) !== $documented || $territory !== $previousTerritory);
$corridorChanged = $old && ($old['corridor_key'] ?? $corridors->key($record)) !== $corridor;
$incompatibleOld = $corridorChanged && $previousTerritory && !$corridors->compatible($corridor, substr($previousTerritory, 10));
if (!$pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && !$incompatibleOld))) {
$incompatibleOld = $corridorChanged && $previousTerritory && ! $corridors->compatible($corridor, substr($previousTerritory, 10));
if (! $pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && ! $incompatibleOld))) {
abort_unless(! empty($choice['confirm_location_change']) && mb_strlen(trim($choice['location_reason'] ?? '')) >= 20,
422, 'Uma localização documentada ou já confirmada só muda por escolha explícita, com confirmação e motivo.');
}
@@ -69,17 +99,17 @@ final class InvestmentOpportunityChoices
$park = $parkKey ? app(InvestorContext::class)->reference($parkKey) : null;
if ($park) {
$eligible = $corridor ? array_column($corridors->parks($corridor, $location['id'] ?? null), 'id') : [];
if (!in_array($park['id'], $eligible, true)) {
abort_if(!empty($choice['park']), 422, 'A zona ou parque não é compatível com a localização desta oportunidade.');
if (! in_array($park['id'], $eligible, true)) {
abort_if(! empty($choice['park']), 422, 'A zona ou parque não é compatível com a localização desta oportunidade.');
$park = null;
}
abort_unless(!$park || $park['kind'] === 'park', 422);
abort_unless(! $park || $park['kind'] === 'park', 422);
}
$result[] = ['opportunity' => $ref, 'territory' => $location, ...($origins ? ['portfolio_origins' => $origins] : []),
'park' => $park,
'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null,
'allowed_provinces' => array_map(fn($p)=>(array)$p, $provinces),
'territory_path' => $location ? array_map(fn ($node) => \Illuminate\Support\Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [],
'allowed_provinces' => array_map(fn ($p) => (array) $p, $provinces),
'territory_path' => $location ? array_map(fn ($node) => Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [],
'activities' => $choice['activities'] ?? ($old['activities'] ?? []),
'documented_territory_id' => $documented, 'location_reason' => $choice['location_reason'] ?? '',
'location_state' => $location ? ($documented && $territory === 'territory:'.$documented ? 'documented' : 'investor_selected') : 'undetermined',
@@ -88,6 +118,7 @@ final class InvestmentOpportunityChoices
'source' => data_get($record->workflow_context, 'corridor_agribusiness.source'),
'source_sha256' => data_get($record->workflow_context, 'corridor_agribusiness.source_sha256')];
}
return $result;
}
}
+158 -56
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\InvestmentZone;
use App\Support\StructuredData;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -11,6 +12,7 @@ use Illuminate\Support\Str;
class InvestmentZoneIntegralDetails
{
public const GROUPS = ['titles', 'phases', 'plots', 'occupants', 'observations', 'chains'];
public const EXTRA = [
'titles' => ['kind', 'identifier', 'valid_from', 'valid_until'],
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
@@ -19,79 +21,179 @@ class InvestmentZoneIntegralDetails
'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'],
];
public const COMMON = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', 'disclosure', 'private_note', 'support_document_ids'];
/**
* @param array<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
{
abort_if(array_diff(array_keys($input), ['schema_version', 'remove_ids', ...self::GROUPS]), 422, 'Campo da ficha integral desconhecido.');
abort_if(array_diff(array_keys($input), ['schema_version', 'remove_ids', ...self::GROUPS]) !== [], 422, 'Campo da ficha integral desconhecido.');
$rules = ['schema_version' => 'sometimes|in:1', 'remove_ids' => 'sometimes|array:'.implode(',', self::GROUPS)];
foreach (self::GROUPS as $group) {
$rules[$group] = 'sometimes|array|max:200';
$rules[$group.'.*'] = 'array:'.implode(',', [...self::COMMON, ...self::EXTRA[$group]]);
foreach (['id'=>'nullable|uuid','label'=>'required|string|max:300','label_en'=>'nullable|string|max:300','description'=>'nullable|string|max:3000','description_en'=>'nullable|string|max:3000','knowledge'=>'required|in:unknown,documented,disputed','source_reference'=>'nullable|string|max:2000','source_date'=>'nullable|date_format:Y-m-d','scope'=>'nullable|string|max:500','disclosure'=>'required|boolean','private_note'=>'nullable|string|max:4000','support_document_ids'=>'sometimes|array|max:20','support_document_ids.*'=>'uuid'] as $key=>$rule) $rules[$group.'.*.'.$key]=$rule;
foreach (self::EXTRA[$group] as $key) $rules[$group.'.*.'.$key]=match ($key) {
'organization_id','phase_id','plot_id','zone_link_id','geometry_id'=>'nullable|uuid',
'area'=>'nullable|numeric|min:0|max:999999999999999',
'value'=>'nullable|numeric|between:-999999999999999,999999999999999',
'starts_at','ends_at','valid_from','valid_until','period_start','period_end'=>'nullable|date_format:Y-m-d',
'expansion'=>'nullable|boolean',
default=>'nullable|string|max:2000',
};
$rules['remove_ids.'.$group]='sometimes|array|max:200';$rules['remove_ids.'.$group.'.*']='uuid';
}
$rules['plots.*.area_unit']='nullable|in:ha,m2';
$rules['plots.*.status']='nullable|in:unknown,planned,available_declared,reserved,occupied,construction,inactive';
$rules['phases.*.status']='nullable|in:unknown,planned,construction,operational,suspended,closed';
$rules['occupants.*.status']='nullable|in:unknown,approved,construction,installed,inactive';
$rules['titles.*.kind']='nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind']='nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator']='required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$data=Validator::make($input,$rules)->validate();$result=$before;
foreach(self::GROUPS as$group){
$rows=collect($before[$group]??[])->keyBy('id')->all();$remove=$data['remove_ids'][$group]??[];$seen=[];
foreach($data[$group]??[]as$item){
$id=$item['id']??(string)Str::uuid();abort_if(in_array($id,$seen,true)||in_array($id,$remove,true),422,'Identificador repetido ou removido na mesma operação.');$seen[]=$id;
$item=[...($rows[$id]??[]),...$item,'id'=>$id];
if($item['knowledge']!=='unknown')abort_unless(trim($item['source_reference']??'')!=='',422,'Indique a fonte documental ou preserve como desconhecido.');
if($item['knowledge']==='unknown')abort_if(isset($item['value'])||isset($item['area']),422,'Quantidade desconhecida deve permanecer vazia, não zero.');
if($item['knowledge']==='unknown'&&!empty($item['status']))abort_unless($item['status']==='unknown',422,'Situação operacional declarada exige fonte ou indicação de divergência.');
foreach([['starts_at','ends_at'],['valid_from','valid_until'],['period_start','period_end']]as[$a,$b])if(isset($item[$a],$item[$b]))abort_if($item[$b]<$item[$a],422,'Período final anterior ao início.');
if($group==='plots'&&isset($item['area']))abort_unless(!empty($item['area_unit'])&&!empty($item['scope']),422,'Área declarada exige unidade e âmbito.');
if($group==='plots'&&($item['status']??'')==='available_declared')abort_unless($item['knowledge']==='documented'&&!empty($item['source_date']),422,'Disponibilidade declarada exige fonte e data; não é disponibilidade em tempo real.');
if($group==='observations'&&isset($item['value']))abort_unless(!empty($item['unit'])&&!empty($item['period_start'])&&!empty($item['period_end'])&&!empty($item['scope'])&&!empty($item['population']),422,'Indicador numérico exige unidade, período, âmbito e base/população.');
if(!empty($item['organization_id']))abort_unless(DB::table('organizations')->where('id',$item['organization_id'])->whereNull('deleted_at')->exists(),422,'Reutilize uma entidade existente.');
if(!empty($item['geometry_id']))abort_unless(DB::table('investment_zone_geometries')->where('zone_id',$zone->id)->where('geometry_id',$item['geometry_id'])->exists(),422,'Geometria deve estar associada a este parque; limite administrativo não é perímetro.');
if(!empty($item['zone_link_id']))abort_unless(DB::table('investment_zone_links')->where('zone_id',$zone->id)->where('id',$item['zone_link_id'])->exists(),422,'Relação económica deve pertencer a este parque.');
foreach($item['support_document_ids']??[]as$doc)abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d','d.id','=','a.document_id')->where('a.zone_id',$zone->id)->where('d.id',$doc)->whereNull('d.deleted_at')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256')->exists(),422,'Suporte deve estar consultável e associado a este parque.');
$rows[$id]=$item;
foreach (['id' => 'nullable|uuid', 'label' => 'required|string|max:300', 'label_en' => 'nullable|string|max:300', 'description' => 'nullable|string|max:3000', 'description_en' => 'nullable|string|max:3000', 'knowledge' => 'required|in:unknown,documented,disputed', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500', 'disclosure' => 'required|boolean', 'private_note' => 'nullable|string|max:4000', 'support_document_ids' => 'sometimes|array|max:20', 'support_document_ids.*' => 'uuid'] as $key => $rule) {
$rules[$group.'.*.'.$key] = $rule;
}
foreach($remove as$id){abort_unless(isset($rows[$id]),422,'O detalhe a remover não pertence ao parque.');unset($rows[$id]);}
abort_if(count($rows)>200,422,'Limite de detalhes excedido.');$result[$group]=array_values($rows);
foreach (self::EXTRA[$group] as $key) {
$rules[$group.'.*.'.$key] = match ($key) {
'organization_id','phase_id','plot_id','zone_link_id','geometry_id' => 'nullable|uuid',
'area' => 'nullable|numeric|min:0|max:999999999999999',
'value' => 'nullable|numeric|between:-999999999999999,999999999999999',
'starts_at','ends_at','valid_from','valid_until','period_start','period_end' => 'nullable|date_format:Y-m-d',
'expansion' => 'nullable|boolean',
default => 'nullable|string|max:2000',
};
}
$rules['remove_ids.'.$group] = 'sometimes|array|max:200';
$rules['remove_ids.'.$group.'.*'] = 'uuid';
}
$rules['plots.*.area_unit'] = 'nullable|in:ha,m2';
$rules['plots.*.status'] = 'nullable|in:unknown,planned,available_declared,reserved,occupied,construction,inactive';
$rules['phases.*.status'] = 'nullable|in:unknown,planned,construction,operational,suspended,closed';
$rules['occupants.*.status'] = 'nullable|in:unknown,approved,construction,installed,inactive';
$rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $group) {
$rows = collect(StructuredData::collectionItems($before[$group] ?? []))->keyBy('id')->all();
$remove = $data['remove_ids'][$group] ?? [];
$seen = [];
foreach ($data[$group] ?? [] as $item) {
$id = $item['id'] ?? (string) Str::uuid();
abort_if(in_array($id, $seen, true) || in_array($id, $remove, true), 422, 'Identificador repetido ou removido na mesma operação.');
$seen[] = $id;
$item = [...($rows[$id] ?? []), ...$item, 'id' => $id];
if ($item['knowledge'] !== 'unknown') {
abort_unless(trim($item['source_reference'] ?? '') !== '', 422, 'Indique a fonte documental ou preserve como desconhecido.');
}
if ($item['knowledge'] === 'unknown') {
abort_if(isset($item['value']) || isset($item['area']), 422, 'Quantidade desconhecida deve permanecer vazia, não zero.');
}
if ($item['knowledge'] === 'unknown' && ! empty($item['status'])) {
abort_unless($item['status'] === 'unknown', 422, 'Situação operacional declarada exige fonte ou indicação de divergência.');
}
foreach ([['starts_at', 'ends_at'], ['valid_from', 'valid_until'], ['period_start', 'period_end']] as [$a,$b]) {
if (isset($item[$a],$item[$b])) {
abort_if($item[$b] < $item[$a], 422, 'Período final anterior ao início.');
}
}
if ($group === 'plots' && isset($item['area'])) {
abort_unless(! empty($item['area_unit']) && ! empty($item['scope']), 422, 'Área declarada exige unidade e âmbito.');
}
if ($group === 'plots' && ($item['status'] ?? '') === 'available_declared') {
abort_unless($item['knowledge'] === 'documented' && ! empty($item['source_date']), 422, 'Disponibilidade declarada exige fonte e data; não é disponibilidade em tempo real.');
}
if ($group === 'observations' && isset($item['value'])) {
abort_unless(! empty($item['unit']) && ! empty($item['period_start']) && ! empty($item['period_end']) && ! empty($item['scope']) && ! empty($item['population']), 422, 'Indicador numérico exige unidade, período, âmbito e base/população.');
}
if (! empty($item['organization_id'])) {
abort_unless(DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->exists(), 422, 'Reutilize uma entidade existente.');
}
if (! empty($item['geometry_id'])) {
abort_unless(DB::table('investment_zone_geometries')->where('zone_id', $zone->id)->where('geometry_id', $item['geometry_id'])->exists(), 422, 'Geometria deve estar associada a este parque; limite administrativo não é perímetro.');
}
if (! empty($item['zone_link_id'])) {
abort_unless(DB::table('investment_zone_links')->where('zone_id', $zone->id)->where('id', $item['zone_link_id'])->exists(), 422, 'Relação económica deve pertencer a este parque.');
}
foreach ($item['support_document_ids'] ?? [] as $doc) {
abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')->where('a.zone_id', $zone->id)->where('d.id', $doc)->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->exists(), 422, 'Suporte deve estar consultável e associado a este parque.');
}
$rows[$id] = $item;
}
foreach ($remove as $id) {
abort_unless(isset($rows[$id]), 422, 'O detalhe a remover não pertence ao parque.');
unset($rows[$id]);
}
abort_if(count($rows) > 200, 422, 'Limite de detalhes excedido.');
$result[$group] = array_values($rows);
}
// References resolve only inside this park, including new items in the same transaction.
foreach(self::GROUPS as$group)foreach($result[$group]as$item){
foreach(['phase_id'=>'phases','plot_id'=>'plots']as$key=>$target)if(!empty($item[$key]))abort_unless(in_array($item[$key],array_column($result[$target],'id'),true),422,'Referência interna inexistente ou de outro parque.');
if(!empty($item['phase_id'])&&!empty($item['plot_id'])){$plot=collect($result['plots'])->firstWhere('id',$item['plot_id']);abort_if(!empty($plot['phase_id'])&&$plot['phase_id']!==$item['phase_id'],422,'Lote e fase não correspondem.');}
foreach (self::GROUPS as $group) {
foreach ($result[$group] as $item) {
foreach (['phase_id' => 'phases', 'plot_id' => 'plots'] as $key => $target) {
if (! empty($item[$key])) {
abort_unless(in_array($item[$key], array_column($result[$target], 'id'), true), 422, 'Referência interna inexistente ou de outro parque.');
}
}
if (! empty($item['phase_id']) && ! empty($item['plot_id'])) {
$plot = collect($result['plots'])->firstWhere('id', $item['plot_id']);
abort_if(! empty($plot['phase_id']) && $plot['phase_id'] !== $item['phase_id'], 422, 'Lote e fase não correspondem.');
}
}
}
foreach(['phases','plots']as$group){$codes=[];foreach($result[$group]as$item)if(trim($item['code']??'')!==''){$code=mb_strtolower(trim($item['code']));abort_if(in_array($code,$codes,true),422,'Código interno repetido no parque.');$codes[]=$code;}}
$keys=[];foreach($result['occupants']as$item){$key=implode('|',[$item['organization_id']??$item['documentary_name']??$item['id'],$item['plot_id']??'',$item['valid_from']??'',$item['valid_until']??'']);abort_if(in_array($key,$keys,true),422,'Ocupante/período já registado; reutilize o detalhe.');$keys[]=$key;}
$keys=[];foreach($result['observations']as$item){if(!isset($item['period_start'],$item['period_end']))continue;$key=implode('|',[$item['indicator'],$item['period_start'],$item['period_end'],$item['scope']??'',$item['population']??'',$item['unit']??'',$item['methodology']??'',$item['source_reference']??'',$item['source_date']??'',$item['phase_id']??'',$item['plot_id']??'']);abort_if(in_array($key,$keys,true),422,'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');$keys[]=$key;}
return [...$result,'schema_version'=>1];
foreach (['phases', 'plots'] as $group) {
$codes = [];
foreach ($result[$group] as $item) {
if (trim($item['code'] ?? '') !== '') {
$code = mb_strtolower(trim($item['code']));
abort_if(in_array($code, $codes, true), 422, 'Código interno repetido no parque.');
$codes[] = $code;
}
}
}
$keys = [];
foreach ($result['occupants'] as $item) {
$key = implode('|', [$item['organization_id'] ?? $item['documentary_name'] ?? $item['id'], $item['plot_id'] ?? '', $item['valid_from'] ?? '', $item['valid_until'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'Ocupante/período já registado; reutilize o detalhe.');
$keys[] = $key;
}
$keys = [];
foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) {
continue;
}$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');
$keys[] = $key;
}
return [...$result, 'schema_version' => 1];
}
/**
* @param array<array-key, mixed> $fields
* @return array<array-key, mixed>
*/
public function publicData(array $fields): array
{
$public=['schema_version'=>1];
foreach(self::GROUPS as$group){$public[$group]=[];foreach($fields[$group]??[]as$item){
if(!($item['disclosure']??false))continue;
$keys=['id','label','label_en','description','description_en','knowledge','source_reference','source_date','scope',...self::EXTRA[$group]];
$visible=array_intersect_key($item,array_flip($keys));unset($visible['organization_id'],$visible['documentary_name'],$visible['geometry_id'],$visible['zone_link_id']);
if(in_array($group,['occupants','chains'],true)&&!empty($item['organization_id'])){$org=DB::table('organizations')->where('id',$item['organization_id'])->whereNull('deleted_at')->where('data_classification','public')->first();abort_unless($org&&$item['knowledge']==='documented',422,'Divulgar entidade exige cadastro público e fonte; a menção preliminar permanece privada.');$visible['organization_name']=$org->name;}
if($group==='occupants')abort_unless(!empty($visible['organization_name']),422,'Ocupante por identificar permanece privado.');
$public[$group][]=$visible;
}}
$public = ['schema_version' => 1];
foreach (self::GROUPS as $group) {
$public[$group] = [];
foreach ($fields[$group] ?? [] as $item) {
if (! ($item['disclosure'] ?? false)) {
continue;
}
$keys = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', ...self::EXTRA[$group]];
$visible = array_intersect_key($item, array_flip($keys));
unset($visible['organization_id'],$visible['documentary_name'],$visible['geometry_id'],$visible['zone_link_id']);
if (in_array($group, ['occupants', 'chains'], true) && ! empty($item['organization_id'])) {
$org = DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->where('data_classification', 'public')->first();
abort_unless($org && $item['knowledge'] === 'documented', 422, 'Divulgar entidade exige cadastro público e fonte; a menção preliminar permanece privada.');
$visible['organization_name'] = $org->name;
}
if ($group === 'occupants') {
abort_unless(! empty($visible['organization_name']), 422, 'Ocupante por identificar permanece privado.');
}
$public[$group][] = $visible;
}
}
// A disclosed item must not leak the identifier of an undisclosed phase or plot.
foreach(self::GROUPS as$group)foreach($public[$group]as&$item)foreach(['phase_id'=>'phases','plot_id'=>'plots']as$key=>$target)if(!empty($item[$key])&&!in_array($item[$key],array_column($public[$target],'id'),true))unset($item[$key]);unset($item);
foreach (self::GROUPS as $group) {
foreach ($public[$group] as &$item) {
foreach (['phase_id' => 'phases', 'plot_id' => 'plots'] as $key => $target) {
if (! empty($item[$key]) && ! in_array($item[$key], array_column($public[$target], 'id'), true)) {
unset($item[$key]);
}
}
}
}unset($item);
return $public;
}
}
+142 -72
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\InvestmentZone;
use App\Support\StructuredData;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -14,119 +15,188 @@ use Illuminate\Validation\ValidationException;
class InvestmentZoneOperationalDetails
{
public const MIGRATION = '2026_10_16_140000_add_investment_zone_operational_details';
public const GROUPS = ['responsibilities', 'conditions', 'risks'];
public function installed(): bool { return Schema::hasColumn('investment_zones', 'operational_details'); }
public function installed(): bool
{
return Schema::hasColumn('investment_zones', 'operational_details');
}
/**
* @return array<array-key, mixed>|null
*/
public function stored(InvestmentZone $zone): ?array
{
$raw = $zone->getRawOriginal('operational_details');
return $raw ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
}
/**
* @return array<array-key, mixed>
*/
public function recover(InvestmentZone $zone): array
{
$stored = $this->stored($zone);
if (!$stored) { return ['schema_version'=>1, 'responsibilities'=>[], 'conditions'=>[], 'risks'=>[], 'review'=>[]]; }
if (! $stored) {
return ['schema_version' => 1, 'responsibilities' => [], 'conditions' => [], 'risks' => [], 'review' => []];
}
try {
if (($stored['schema_version'] ?? null) !== 1) { throw new \RuntimeException('Unsupported supplement'); }
if (($stored['schema_version'] ?? null) !== 1) {
throw new \RuntimeException('Unsupported supplement');
}
return json_decode(Crypt::decryptString($stored['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
throw ValidationException::withMessages(['operational_details'=>'Não foi possível recuperar os detalhes privados. Os dados foram preservados; contacte o suporte.']);
throw ValidationException::withMessages(['operational_details' => 'Não foi possível recuperar os detalhes privados. Os dados foram preservados; contacte o suporte.']);
}
}
/**
* @return array<array-key, mixed>
*/
public function publicData(InvestmentZone $zone): array
{
return $this->stored($zone)['public'] ?? [];
}
/** Merge only supplied keys. An absent group/item is preserved; removal requires explicit IDs. */
/** Merge only supplied keys. An absent group/item is preserved; removal requires explicit IDs.
* @param array<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
{
abort_unless($zone->category==='industrial_park',422,'O complemento L1 aplica-se à ficha de parques.');
abort_unless($this->installed(),503,'Detalhes L1 ainda não instalados. A ficha pública foi preservada.');
$rules=['integral'=>'sometimes|array','schema_version'=>'sometimes|in:1','responsibilities'=>'sometimes|array|max:40','conditions'=>'sometimes|array|max:60','risks'=>'sometimes|array|max:40',
'remove_ids'=>'sometimes|array:responsibilities,conditions,risks','review'=>'sometimes|array:source_reference,source_date,scope,confidence,last_reviewed_at,next_review_at,private_note',
'review.confidence'=>'nullable|in:unknown,reported,documented,disputed','review.last_reviewed_at'=>'nullable|date_format:Y-m-d','review.next_review_at'=>'nullable|date_format:Y-m-d','review.private_note'=>'nullable|string|max:4000'];
$common='id,label,knowledge,disclosure,source_reference,source_date,scope,private_note,support_document_ids';
foreach(self::GROUPS as $g){
$extra=$g==='responsibilities'?',role,organization_id,documentary_name,private_contact,public_contact,declared_nature,management_model':($g==='conditions'?',value,currency,unit,period,valid_from,valid_until,modality':',risk_type,mitigation,responsible_role,channel,channel_owner');
$rules[$g.'.*']=['array:'.$common.$extra];
foreach(['id'=>'nullable|uuid','label'=>'required|string|max:300','knowledge'=>'required|in:unknown,documented,disputed','disclosure'=>'required|boolean',
'source_reference'=>'nullable|string|max:2000','source_date'=>'nullable|date_format:Y-m-d','scope'=>'nullable|string|max:500','private_note'=>'nullable|string|max:4000',
'support_document_ids'=>'sometimes|array|max:10','support_document_ids.*'=>'uuid'] as $k=>$r){$rules[$g.'.*.'.$k]=$r;}
$rules['remove_ids.'.$g]='sometimes|array|max:60';$rules['remove_ids.'.$g.'.*']='uuid';
abort_unless($zone->category === 'industrial_park', 422, 'O complemento L1 aplica-se à ficha de parques.');
abort_unless($this->installed(), 503, 'Detalhes L1 ainda não instalados. A ficha pública foi preservada.');
$rules = ['integral' => 'sometimes|array', 'schema_version' => 'sometimes|in:1', 'responsibilities' => 'sometimes|array|max:40', 'conditions' => 'sometimes|array|max:60', 'risks' => 'sometimes|array|max:40',
'remove_ids' => 'sometimes|array:responsibilities,conditions,risks', 'review' => 'sometimes|array:source_reference,source_date,scope,confidence,last_reviewed_at,next_review_at,private_note',
'review.confidence' => 'nullable|in:unknown,reported,documented,disputed', 'review.last_reviewed_at' => 'nullable|date_format:Y-m-d', 'review.next_review_at' => 'nullable|date_format:Y-m-d', 'review.private_note' => 'nullable|string|max:4000'];
$common = 'id,label,knowledge,disclosure,source_reference,source_date,scope,private_note,support_document_ids';
foreach (self::GROUPS as $g) {
$extra = $g === 'responsibilities' ? ',role,organization_id,documentary_name,private_contact,public_contact,declared_nature,management_model' : ($g === 'conditions' ? ',value,currency,unit,period,valid_from,valid_until,modality' : ',risk_type,mitigation,responsible_role,channel,channel_owner');
$rules[$g.'.*'] = ['array:'.$common.$extra];
foreach (['id' => 'nullable|uuid', 'label' => 'required|string|max:300', 'knowledge' => 'required|in:unknown,documented,disputed', 'disclosure' => 'required|boolean',
'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500', 'private_note' => 'nullable|string|max:4000',
'support_document_ids' => 'sometimes|array|max:10', 'support_document_ids.*' => 'uuid'] as $k => $r) {
$rules[$g.'.*.'.$k] = $r;
}
$rules['remove_ids.'.$g] = 'sometimes|array|max:60';
$rules['remove_ids.'.$g.'.*'] = 'uuid';
}
foreach(['source_reference'=>'nullable|string|max:2000','source_date'=>'nullable|date_format:Y-m-d','scope'=>'nullable|string|max:500']as$k=>$r){$rules['review.'.$k]=$r;}
foreach(['role'=>'required|in:owner,promoter,operator,manager','organization_id'=>'nullable|uuid','documentary_name'=>'nullable|string|max:300','private_contact'=>'nullable|string|max:1000','public_contact'=>'nullable|string|max:1000','declared_nature'=>'nullable|string|max:300','management_model'=>'nullable|string|max:500']as$k=>$r){$rules['responsibilities.*.'.$k]=$r;}
foreach(['value'=>'nullable|numeric|min:0','currency'=>'nullable|in:MZN,USD,EUR,ZAR','unit'=>'nullable|string|max:80','period'=>'nullable|string|max:80','valid_from'=>'nullable|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d','modality'=>'nullable|string|max:300']as$k=>$r){$rules['conditions.*.'.$k]=$r;}
foreach(['risk_type'=>'nullable|string|max:300','mitigation'=>'nullable|string|max:2000','responsible_role'=>'nullable|in:owner,promoter,operator,manager,unknown','channel'=>'nullable|string|max:1000','channel_owner'=>'nullable|in:park,tempo,unknown']as$k=>$r){$rules['risks.*.'.$k]=$r;}
abort_if(array_diff(array_keys($input),['schema_version',...self::GROUPS,'review','remove_ids','integral']),422,'Campo L1 não reconhecido.');
$data=Validator::make($input,$rules)->validate();$result=$before;
foreach(self::GROUPS as $g){
$rows=collect($before[$g] ?? [])->keyBy('id')->all();$removals=$data['remove_ids'][$g] ?? []; $seen=[];
foreach($data[$g] ?? [] as $item){
$id=$item['id'] ?? (string)Str::uuid();
abort_if(in_array($id,$seen,true)||in_array($id,$removals,true),422,'Identificador L1 repetido ou removido na mesma operação.');$seen[]=$id;
$item=[...($rows[$id] ?? []),...$item,'id'=>$id];
if($item['knowledge']==='unknown') { abort_if(isset($item['value']),422,'Valor desconhecido deve permanecer vazio, nunca zero por defeito.'); }
else { abort_unless(trim($item['source_reference'] ?? '')!=='',422,'Identifique a fonte da informação documentada ou divergente.'); }
if($g==='conditions' && isset($item['value'])){
abort_unless(trim($item['currency'] ?? '')!=='' && trim($item['unit'] ?? '')!=='' && trim($item['scope'] ?? '')!=='',422,'Uma tarifa numérica exige moeda, unidade/base e âmbito; pode manter o valor desconhecido.');
foreach (['source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500'] as $k => $r) {
$rules['review.'.$k] = $r;
}
foreach (['role' => 'required|in:owner,promoter,operator,manager', 'organization_id' => 'nullable|uuid', 'documentary_name' => 'nullable|string|max:300', 'private_contact' => 'nullable|string|max:1000', 'public_contact' => 'nullable|string|max:1000', 'declared_nature' => 'nullable|string|max:300', 'management_model' => 'nullable|string|max:500'] as $k => $r) {
$rules['responsibilities.*.'.$k] = $r;
}
foreach (['value' => 'nullable|numeric|min:0', 'currency' => 'nullable|in:MZN,USD,EUR,ZAR', 'unit' => 'nullable|string|max:80', 'period' => 'nullable|string|max:80', 'valid_from' => 'nullable|date_format:Y-m-d', 'valid_until' => 'nullable|date_format:Y-m-d', 'modality' => 'nullable|string|max:300'] as $k => $r) {
$rules['conditions.*.'.$k] = $r;
}
foreach (['risk_type' => 'nullable|string|max:300', 'mitigation' => 'nullable|string|max:2000', 'responsible_role' => 'nullable|in:owner,promoter,operator,manager,unknown', 'channel' => 'nullable|string|max:1000', 'channel_owner' => 'nullable|in:park,tempo,unknown'] as $k => $r) {
$rules['risks.*.'.$k] = $r;
}
abort_if(array_diff(array_keys($input), ['schema_version', ...self::GROUPS, 'review', 'remove_ids', 'integral']) !== [], 422, 'Campo L1 não reconhecido.');
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $g) {
$rows = collect(StructuredData::collectionItems($before[$g] ?? []))->keyBy('id')->all();
$removals = $data['remove_ids'][$g] ?? [];
$seen = [];
foreach ($data[$g] ?? [] as $item) {
$id = $item['id'] ?? (string) Str::uuid();
abort_if(in_array($id, $seen, true) || in_array($id, $removals, true), 422, 'Identificador L1 repetido ou removido na mesma operação.');
$seen[] = $id;
$item = [...($rows[$id] ?? []), ...$item, 'id' => $id];
if ($item['knowledge'] === 'unknown') {
abort_if(isset($item['value']), 422, 'Valor desconhecido deve permanecer vazio, nunca zero por defeito.');
} else {
abort_unless(trim($item['source_reference'] ?? '') !== '', 422, 'Identifique a fonte da informação documentada ou divergente.');
}
if($g==='conditions' && isset($item['valid_from'],$item['valid_until'])){
abort_if($item['valid_until']<$item['valid_from'],422,'O fim da vigência é anterior ao início; preserve datas desconhecidas como desconhecidas.');
if ($g === 'conditions' && isset($item['value'])) {
abort_unless(trim($item['currency'] ?? '') !== '' && trim($item['unit'] ?? '') !== '' && trim($item['scope'] ?? '') !== '', 422, 'Uma tarifa numérica exige moeda, unidade/base e âmbito; pode manter o valor desconhecido.');
}
if($g==='risks' && !empty($item['channel']) && ($item['channel_owner'] ?? 'unknown')==='park'){
abort_unless($item['knowledge']==='documented' && trim($item['source_reference'] ?? '')!=='',422,'O canal próprio do parque exige fonte; pode manter o canal desconhecido ou usar o apoio da Tempo identificado como tal.');
if ($g === 'conditions' && isset($item['valid_from'],$item['valid_until'])) {
abort_if($item['valid_until'] < $item['valid_from'], 422, 'O fim da vigência é anterior ao início; preserve datas desconhecidas como desconhecidas.');
}
if($g==='responsibilities'){
$org=$item['organization_id'] ?? null;
if($org){abort_unless(DB::table('organizations')->where('id',$org)->whereNull('deleted_at')->exists(),422,'Reutilize uma entidade existente ou mantenha a menção documental preliminar.');}
if($item['role']==='operator' && $org){
$operator=$operatorId;
abort_unless($operator===$org,422,'O operador é a associação canónica da ficha; não crie outro no suplemento.');
if ($g === 'risks' && ! empty($item['channel']) && ($item['channel_owner'] ?? 'unknown') === 'park') {
abort_unless($item['knowledge'] === 'documented', 422, 'O canal próprio do parque exige fonte; pode manter o canal desconhecido ou usar o apoio da Tempo identificado como tal.');
}
if ($g === 'responsibilities') {
$org = $item['organization_id'] ?? null;
if ($org) {
abort_unless(DB::table('organizations')->where('id', $org)->whereNull('deleted_at')->exists(), 422, 'Reutilize uma entidade existente ou mantenha a menção documental preliminar.');
}
if ($item['role'] === 'operator' && $org) {
$operator = $operatorId;
abort_unless($operator === $org, 422, 'O operador é a associação canónica da ficha; não crie outro no suplemento.');
}
}
foreach($item['support_document_ids'] ?? []as$document){
abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d','d.id','=','a.document_id')->where('a.zone_id',$zone->id)->where('d.id',$document)
->whereNull('d.deleted_at')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256')->exists(),422,'Selecione suporte consultável já associado à ficha; a publicação do anexo é independente.');
foreach ($item['support_document_ids'] ?? [] as $document) {
abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')->where('a.zone_id', $zone->id)->where('d.id', $document)
->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->exists(), 422, 'Selecione suporte consultável já associado à ficha; a publicação do anexo é independente.');
}
$rows[$id]=$item;
$rows[$id] = $item;
}
foreach($removals as$id){abort_unless(isset($rows[$id]),422,'Detalhe a remover não pertence à ficha.');unset($rows[$id]);}
abort_if(count($rows)>($g==='conditions'?60:40),422,'Limite de detalhes por grupo excedido.');
$result[$g]=array_values($rows);
foreach ($removals as $id) {
abort_unless(isset($rows[$id]), 422, 'Detalhe a remover não pertence à ficha.');
unset($rows[$id]);
}
abort_if(count($rows) > ($g === 'conditions' ? 60 : 40), 422, 'Limite de detalhes por grupo excedido.');
$result[$g] = array_values($rows);
}
$pairs=[];foreach($result['responsibilities'] as$item){$key=$item['role'].'|'.($item['organization_id'] ?? $item['documentary_name'] ?? $item['id']);abort_if(in_array($key,$pairs,true),422,'A mesma entidade/função já está registada; reutilize o detalhe existente.');$pairs[]=$key;}
if(array_key_exists('review',$data)){$result['review']=[...($before['review'] ?? []),...$data['review']];}
if(array_key_exists('integral',$data)){$result['integral']=app(InvestmentZoneIntegralDetails::class)->merge($zone,$before['integral'] ?? [],$data['integral']);}
$result['schema_version']=1;
$pairs = [];
foreach ($result['responsibilities'] as $item) {
$key = $item['role'].'|'.($item['organization_id'] ?? $item['documentary_name'] ?? $item['id']);
abort_if(in_array($key, $pairs, true), 422, 'A mesma entidade/função já está registada; reutilize o detalhe existente.');
$pairs[] = $key;
}
if (array_key_exists('review', $data)) {
$result['review'] = [...($before['review'] ?? []), ...$data['review']];
}
if (array_key_exists('integral', $data)) {
$result['integral'] = app(InvestmentZoneIntegralDetails::class)->merge($zone, $before['integral'] ?? [], $data['integral']);
}
$result['schema_version'] = 1;
return $result;
}
/**
* @param array<array-key, mixed> $fields
* @return array<array-key, mixed>
*/
public function pack(array $fields): array
{
$public=[];
foreach(self::GROUPS as$g){
$public[$g]=[];
foreach($fields[$g] ?? []as$item){
if(!($item['disclosure'] ?? false)){continue;}
$keys=['label','knowledge','source_reference','source_date','scope'];
if($g==='conditions'){$keys=[...$keys,'value','currency','unit','period','valid_from','valid_until','modality'];}
if($g==='risks'){$keys=[...$keys,'risk_type','mitigation','responsible_role','channel','channel_owner'];}
$visible=array_intersect_key($item,array_flip($keys));
if($g==='responsibilities'){
$org=empty($item['organization_id'])?null:DB::table('organizations')->where('id',$item['organization_id'])->whereNull('deleted_at')->where('data_classification','public')->first();
abort_unless($org && $item['knowledge']==='documented',422,'Divulgação de função exige entidade pública existente e fundamento; menção preliminar continua privada.');
$visible=[...$visible,'role'=>$item['role'],'organization_name'=>$org->name,'public_contact'=>$item['public_contact'] ?? null,'declared_nature'=>$item['declared_nature'] ?? null,'management_model'=>$item['management_model'] ?? null];
$public = [];
foreach (self::GROUPS as $g) {
$public[$g] = [];
foreach ($fields[$g] ?? [] as $item) {
if (! ($item['disclosure'] ?? false)) {
continue;
}
$public[$g][]=$visible;
$keys = ['label', 'knowledge', 'source_reference', 'source_date', 'scope'];
if ($g === 'conditions') {
$keys = [...$keys, 'value', 'currency', 'unit', 'period', 'valid_from', 'valid_until', 'modality'];
}
if ($g === 'risks') {
$keys = [...$keys, 'risk_type', 'mitigation', 'responsible_role', 'channel', 'channel_owner'];
}
$visible = array_intersect_key($item, array_flip($keys));
if ($g === 'responsibilities') {
$org = empty($item['organization_id']) ? null : DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->where('data_classification', 'public')->first();
abort_unless($org && $item['knowledge'] === 'documented', 422, 'Divulgação de função exige entidade pública existente e fundamento; menção preliminar continua privada.');
$visible = [...$visible, 'role' => $item['role'], 'organization_name' => $org->name, 'public_contact' => $item['public_contact'] ?? null, 'declared_nature' => $item['declared_nature'] ?? null, 'management_model' => $item['management_model'] ?? null];
}
$public[$g][] = $visible;
}
}
if(isset($fields['integral'])){$public['integral']=app(InvestmentZoneIntegralDetails::class)->publicData($fields['integral']);}
$public['review']=array_intersect_key($fields['review'] ?? [],array_flip(['source_reference','source_date','scope','confidence','last_reviewed_at','next_review_at']));
return ['schema_version'=>1,'public'=>$public,'private_ciphertext'=>Crypt::encryptString(json_encode($fields,JSON_THROW_ON_ERROR))];
if (isset($fields['integral'])) {
$public['integral'] = app(InvestmentZoneIntegralDetails::class)->publicData($fields['integral']);
}
$public['review'] = array_intersect_key($fields['review'] ?? [], array_flip(['source_reference', 'source_date', 'scope', 'confidence', 'last_reviewed_at', 'next_review_at']));
return ['schema_version' => 1, 'public' => $public, 'private_ciphertext' => Crypt::encryptString(json_encode($fields, JSON_THROW_ON_ERROR))];
}
}
@@ -3,8 +3,10 @@
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -12,75 +14,140 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
/** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class InvestmentZoneOperationalDetailsInstallation
{
public const SOURCE='investment-zone-details-installation';
public const SOURCE = 'investment-zone-details-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Models/InvestmentZone.php', 'app/Http/Controllers/InvestmentZoneController.php', 'app/Services/InvestmentZones.php', 'app/Services/InvestmentZoneOperationalProfile.php', 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investment/zones.tsx', 'routes/investment-zones.php', 'resources/js/components/investment-zone-installation.tsx', 'app/Services/InvestmentZoneOperationalDetails.php', 'app/Services/InvestmentZoneOperationalDetailsInstallation.php', 'resources/js/components/investment-zone-operational-details.tsx', 'database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php'];
}
public function fingerprint(): string
{
$files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files());
foreach(\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $file){$files[]=['public/build/'.$file->getRelativePathname(),hash_file('sha256',$file->getPathname())];}
usort($files,fn($a,$b)=>strcmp($a[0],$b[0]));
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR));
$files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files());
foreach (File::allFiles(public_path('build')) as $file) {
$files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
}
usort($files, fn ($a, $b) => strcmp($a[0], $b[0]));
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
private function authorize(User $actor,bool $write=false): void
private function authorize(User $actor, bool $write = false): void
{
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);}
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.');
if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
}
private function evidence(array $target,string $code): array
/**
* @param array<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')
&& config('database.connections.pgsql.host')==='127.0.0.1' && !config('database.connections.pgsql.url');
if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];}
if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];}
$path=storage_path('app/private/investmoz-v2/park-details/readiness.json');
$p=is_file($path)?json_decode(file_get_contents($path),true):[];$date=strtotime($p['verified_at'] ?? '');$auth=$p['active_authorization'] ?? [];
$ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'),['127.0.0.1','localhost'],true)
&& ($p['target']['database'] ?? '')===$target['database'] && (int)($p['target']['port'] ?? 0)===(int)$target['port']
&& ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400
$qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test')
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if ($qa) {
return ['ready' => true, 'mode' => 'isolated_QA'];
}
if (app()->environment('testing')) {
return ['ready' => false, 'mode' => 'invalid_QA'];
}
$path = storage_path('app/private/investmoz-v2/park-details/readiness.json');
$p = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
$date = strtotime($p['verified_at'] ?? '');
$auth = $p['active_authorization'] ?? [];
$ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
&& ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port']
&& ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400
&& ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false)
&& ($p['restored_boot_verified'] ?? false) && (int)($p['restore_target']['port'] ?? 0)===55441 && str_ends_with($p['restore_target']['database'] ?? '','_test')
&& ($auth['decision_record'] ?? '')!=='' && ($auth['code_hash'] ?? '')===$code && ($auth['migration'] ?? '')===InvestmentZoneOperationalDetails::MIGRATION;
foreach(['dump','files'] as $kind){$file=$p[$kind.'_path'] ?? ''; $ready=$ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '',hash_file('sha256',$file));}
return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null];
&& ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test')
&& ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === InvestmentZoneOperationalDetails::MIGRATION;
foreach (['dump', 'files'] as $kind) {
$file = $p[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file));
}
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);$target=(array)DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');$code=$this->fingerprint();
$ledgerCount=DB::table('migrations')->where('migration',InvestmentZoneOperationalDetails::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('investment_zones','operational_details');
$shape=DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='operational_details'");
$correct=$shape && $shape->data_type==='jsonb' && $shape->is_nullable==='YES';
$consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[];
if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';}
try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();}
$evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';}
if(!app(InvestmentZoneOperationalProfile::class)->installed() || DB::table('migrations')->where('migration',InvestmentZoneOperationalProfile::MIGRATION)->count()!==1){$reasons[]='Base 130000 ausente ou divergente; instalar/verificar antes de L1.';}
$state=['target'=>$target,'migration'=>InvestmentZoneOperationalDetails::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column,
'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence];
return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))];
$this->authorize($actor);
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$code = $this->fingerprint();
$ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalDetails::MIGRATION)->count();
$ledger = $ledgerCount === 1;
$column = Schema::hasColumn('investment_zones', 'operational_details');
$shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='operational_details'");
$correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES';
$consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
$reasons = [];
if (! $consistent) {
$reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.';
}
try {
$this->authorize($actor, true);
} catch (HttpException $e) {
$reasons[] = $e->getMessage();
}
$evidence = $this->evidence($target, $code);
if (! $evidence['ready']) {
$reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';
}
if (! app(InvestmentZoneOperationalProfile::class)->installed() || DB::table('migrations')->where('migration', InvestmentZoneOperationalProfile::MIGRATION)->count() !== 1) {
$reasons[] = 'Base 130000 ausente ou divergente; instalar/verificar antes de L1.';
}
$state = ['target' => $target, 'migration' => InvestmentZoneOperationalDetails::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column,
'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
public function apply(User $actor,array $input): array
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{
$this->authorize($actor,true);
abort_if(array_diff(array_keys($input),['hash','idempotency_key','confirm','_token']),422,'Ator, migration ou caminho arbitrário não permitido.');
$data=Validator::make($input,['hash'=>'required|string|size:64','idempotency_key'=>'required|uuid','confirm'=>'required|accepted'])->validate();
return DB::transaction(function()use($actor,$data){
DB::select('SELECT pg_advisory_xact_lock(161026,140000)');$requestHash=hash('sha256',json_encode($data,JSON_THROW_ON_ERROR));
$old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first();
if($old){$receipt=json_decode($old->after_data,true);abort_unless((int)$old->actor_user_id===$actor->id && hash_equals($receipt['request_hash'],$requestHash),409,'Chave já utilizada com outro ator ou conteúdo.');return [...$receipt['result'],'replayed'=>true];}
$plan=$this->preview($actor);abort_unless(hash_equals($plan['hash'],$data['hash']),409,'Prévia alterada; consulte novamente.');abort_unless($plan['ready'],422,implode(' ',$plan['reasons']));
if(!$plan['installed']){
config(['investment-zone-details.authenticated_installation'=>true]);
try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.InvestmentZoneOperationalDetails::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');}
finally{config(['investment-zone-details.authenticated_installation'=>false]);}
$this->authorize($actor, true);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::select('SELECT pg_advisory_xact_lock(161026,140000)');
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if ($old) {
$receipt = json_decode($old->after_data, true);
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.');
return [...$receipt['result'], 'replayed' => true];
}
abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.');
$result=['migration'=>$plan['migration'],'code_hash'=>$plan['code_hash'],'target'=>$plan['target'],'changed'=>!$plan['installed'],'business_records_changed'=>0,'replayed'=>false,'receipt'=>$data['idempotency_key']];
DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'investment-zone-profile','entity_id'=>null,'actor_user_id'=>$actor->id,
'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]);
$plan = $this->preview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
if (! $plan['installed']) {
config(['investment-zone-details.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.InvestmentZoneOperationalDetails::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.');
} finally {
config(['investment-zone-details.authenticated_installation' => false]);
}
}
abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.');
$result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'investment-zone-profile', 'entity_id' => null, 'actor_user_id' => $actor->id,
'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
+128 -73
View File
@@ -13,131 +13,186 @@ use Illuminate\Validation\ValidationException;
class InvestmentZoneOperationalProfile
{
public const MIGRATION = '2026_10_16_130000_add_investment_zone_editorial_draft';
public const FIELDS = ['code','category','name','name_en','description','description_en','province','district','operator_id','legal_source_id',
'physical_category','legal_regime','operational_status','development_status','availability_status','area_components','source_evidence',
'area_value','area_unit','aliases','activities','resources','data_as_of','sources_consulted_at','parent_zone_id','historical_territory'];
public const DETAIL_PUBLIC = ['id','name','status','source_reference','source_date','kind','scope','capacity_type','capacity_value','capacity_unit','focus','economic_activity_id'];
public function installed(): bool { return Schema::hasColumn('investment_zones', 'editorial_draft'); }
public const FIELDS = ['code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'operator_id', 'legal_source_id',
'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_components', 'source_evidence',
'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory'];
public const DETAIL_PUBLIC = ['id', 'name', 'status', 'source_reference', 'source_date', 'kind', 'scope', 'capacity_type', 'capacity_value', 'capacity_unit', 'focus', 'economic_activity_id'];
public function installed(): bool
{
return Schema::hasColumn('investment_zones', 'editorial_draft');
}
/**
* @return array<array-key, mixed>|null
*/
public function draft(InvestmentZone $zone): ?array
{
$raw = $zone->getRawOriginal('editorial_draft');
if (! $raw) { return null; }
try { $data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR); }
catch (\Throwable $e) { throw ValidationException::withMessages(['editorial_draft'=>'Não foi possível recuperar a preparação privada. Os dados foram preservados; contacte o suporte.']); }
if (! $raw) {
return null;
}
try {
$data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
throw ValidationException::withMessages(['editorial_draft' => 'Não foi possível recuperar a preparação privada. Os dados foram preservados; contacte o suporte.']);
}
abort_unless(($data['schema_version'] ?? null) === 1 && is_array($data['fields'] ?? null), 409, 'Formato de preparação incompatível; os dados foram preservados.');
return $data;
}
public function workingVersion(InvestmentZone $zone): int { return (int) ($this->draft($zone)['working_version'] ?? $zone->version_no); }
public function workingVersion(InvestmentZone $zone): int
{
return (int) ($this->draft($zone)['working_version'] ?? $zone->version_no);
}
public function nextVersion(InvestmentZone $zone): int
{
return max((int) $zone->version_no, (int) DB::table('investment_zone_revisions')->where('zone_id',$zone->id)->max('version_no')) + 1;
return max((int) $zone->version_no, (int) DB::table('investment_zone_revisions')->where('zone_id', $zone->id)->max('version_no')) + 1;
}
/**
* @return array<array-key, mixed>
*/
public function management(InvestmentZone $zone): array
{
$draft = $this->draft($zone);
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no'=>$draft['working_version'] ?? $zone->version_no,
'public_version_no'=>$zone->version_no, 'base_public_version'=>$draft['base_public_version'] ?? $zone->version_no,
'has_private_draft'=>$draft !== null, 'profile_available'=>$this->installed(),
'operational_details'=>($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
'details_available'=>app(InvestmentZoneOperationalDetails::class)->installed()];
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no,
'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no,
'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(),
'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
'details_available' => app(InvestmentZoneOperationalDetails::class)->installed()];
}
/**
* @param array<array-key, mixed> $record
* @return array<array-key, mixed>
*/
public function publicDetails(array $record): array
{
unset($record['editorial_draft'], $record['operational_details']);
foreach (['activities','resources'] as $key) {
if (isset($record[$key])) { $record[$key] = array_map(fn ($item)=>array_intersect_key($item,array_flip(self::DETAIL_PUBLIC)), $record[$key]); }
foreach (['activities', 'resources'] as $key) {
if (isset($record[$key])) {
$record[$key] = array_map(fn ($item) => array_intersect_key($item, array_flip(self::DETAIL_PUBLIC)), $record[$key]);
}
}
return $record;
}
private function mergeDetails(string $zoneId,array $before, array $after): array
/**
* @param array<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) {
$old = collect($before)->first(fn($b)=>isset($item['id']) ? ($b['id'] ?? null)===$item['id'] : ($b['name'] ?? null)===($item['name'] ?? null));
$item=[...($old ?? []), ...$item];
$old = collect($before)->first(fn ($b) => isset($item['id']) ? ($b['id'] ?? null) === $item['id'] : ($b['name'] ?? null) === ($item['name'] ?? null));
$item = [...($old ?? []), ...$item];
$item['id'] ??= (string) Str::uuid();
if (isset($item['capacity_value']) && empty($item['capacity_unit'])) { throw ValidationException::withMessages(['resources'=>'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']); }
if (!empty($item['economic_activity_id'])) {
abort_unless(trim((string)($item['source_reference'] ?? '')) !== '' && DB::table('economic_activities')->where('id',$item['economic_activity_id'])->where('data_classification','public')->whereNull('deleted_at')->exists(),422,'A correspondência CAE exige referência publicada e fundamento explícito.');
if (isset($item['capacity_value']) && empty($item['capacity_unit'])) {
throw ValidationException::withMessages(['resources' => 'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']);
}
if (! empty($item['economic_activity_id'])) {
abort_unless(trim((string) ($item['source_reference'] ?? '')) !== '' && DB::table('economic_activities')->where('id', $item['economic_activity_id'])->where('data_classification', 'public')->whereNull('deleted_at')->exists(), 422, 'A correspondência CAE exige referência publicada e fundamento explícito.');
}
foreach ($item['support_document_ids'] ?? [] as $document) {
abort_unless(DB::table('investment_zone_attachments')->where('zone_id',$zoneId)->where('document_id',$document)->exists(),422,'Selecione suporte privado já associado a esta ficha.');
$d=DB::table('documents')->where('id',$document)->whereNull('deleted_at')->first();
abort_unless($d && $d->status==='active' && $d->malware_scan_status==='clean' && trim($d->sha256)===$d->malware_scanned_sha256,422,'Documento de suporte ainda não consultável.');
abort_unless(DB::table('investment_zone_attachments')->where('zone_id', $zoneId)->where('document_id', $document)->exists(), 422, 'Selecione suporte privado já associado a esta ficha.');
$d = DB::table('documents')->where('id', $document)->whereNull('deleted_at')->first();
abort_unless($d && $d->status === 'active' && $d->malware_scan_status === 'clean' && trim($d->sha256) === $d->malware_scanned_sha256, 422, 'Documento de suporte ainda não consultável.');
}
$result[]=$item;
$result[] = $item;
}
return $result;
}
/** Called with zone row locked by InvestmentZones. Only this encrypted column is written. */
public function prepare(InvestmentZone $zone,array $data,int $actor): InvestmentZone
/** Called with zone row locked by InvestmentZones. Only this encrypted column is written.
* @param array<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((int)$data['version_no']===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de guardar.');
$previous=$this->draft($zone);
$base=$previous['fields'] ?? array_intersect_key($zone->toArray(),array_flip(self::FIELDS));
$fields=[...$base,...array_intersect_key($data,array_flip(self::FIELDS))];
$details=app(InvestmentZoneOperationalDetails::class);
if (array_key_exists('operational_details',$data)) {
$fields['operational_details']=$details->merge($zone,$base['operational_details'] ?? $details->recover($zone),$data['operational_details'],$fields['operator_id'] ?? null);
} elseif ($details->stored($zone) && !array_key_exists('operational_details',$fields)) {
$fields['operational_details']=$details->recover($zone);
abort_unless($this->installed(), 503, 'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.');
abort_unless((int) $data['version_no'] === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de guardar.');
$previous = $this->draft($zone);
$base = $previous['fields'] ?? array_intersect_key($zone->toArray(), array_flip(self::FIELDS));
$fields = [...$base, ...array_intersect_key($data, array_flip(self::FIELDS))];
$details = app(InvestmentZoneOperationalDetails::class);
if (array_key_exists('operational_details', $data)) {
$fields['operational_details'] = $details->merge($zone, $base['operational_details'] ?? $details->recover($zone), $data['operational_details'], $fields['operator_id'] ?? null);
} elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) {
$fields['operational_details'] = $details->recover($zone);
}
$details=app(InvestmentZoneOperationalDetails::class);
if (array_key_exists('operational_details',$data)) {
$fields['operational_details']=$details->merge($zone,$base['operational_details'] ?? $details->recover($zone),$data['operational_details'],$fields['operator_id'] ?? null);
} elseif ($details->stored($zone) && !array_key_exists('operational_details',$fields)) {
$fields['operational_details']=$details->recover($zone);
$details = app(InvestmentZoneOperationalDetails::class);
if (array_key_exists('operational_details', $data)) {
$fields['operational_details'] = $details->merge($zone, $base['operational_details'] ?? $details->recover($zone), $data['operational_details'], $fields['operator_id'] ?? null);
} elseif ($details->stored($zone) && ! array_key_exists('operational_details', $fields)) {
$fields['operational_details'] = $details->recover($zone);
}
foreach (['activities','resources'] as $key) { if (array_key_exists($key,$data)) { $fields[$key]=$this->mergeDetails($zone->id,$base[$key] ?? [],$data[$key]); } }
$draft=['schema_version'=>1,'base_public_version'=>$previous['base_public_version'] ?? (int)$zone->version_no,
'working_version'=>$this->nextVersion($zone),'fields'=>$fields,'actor_id'=>$actor,'prepared_at'=>now()->toIso8601String()];
DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]);
$this->snapshot($zone,$draft['working_version'],$actor,'profile_prepared',['public'=>[...$zone->toArray(),'operational_details'=>$details->stored($zone)],'draft'=>$draft]);
foreach (['activities', 'resources'] as $key) {
if (array_key_exists($key, $data)) {
$fields[$key] = $this->mergeDetails($zone->id, $base[$key] ?? [], $data[$key]);
}
}
$draft = ['schema_version' => 1, 'base_public_version' => $previous['base_public_version'] ?? (int) $zone->version_no,
'working_version' => $this->nextVersion($zone), 'fields' => $fields, 'actor_id' => $actor, 'prepared_at' => now()->toIso8601String()];
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]);
return $zone->fresh();
}
/** Apply only inside the publication transaction; a failed validation rolls everything back. */
public function promote(InvestmentZone $zone,int $version): void
public function promote(InvestmentZone $zone, int $version): void
{
$draft=$this->draft($zone);
abort_unless($version===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de publicar.');
if (!$draft) { return; }
abort_unless((int)$draft['base_public_version']===(int)$zone->version_no,409,'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.');
$zone->fill(array_intersect_key($draft['fields'],array_flip(self::FIELDS)));
if (array_key_exists('operational_details',$draft['fields'])) {
$zone->setAttribute('operational_details',app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details']));
$draft = $this->draft($zone);
abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.');
if (! $draft) {
return;
}
abort_unless((int) $draft['base_public_version'] === (int) $zone->version_no, 409, 'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.');
$zone->fill(array_intersect_key($draft['fields'], array_flip(self::FIELDS)));
if (array_key_exists('operational_details', $draft['fields'])) {
$zone->setAttribute('operational_details', app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details']));
}
// Existing resource support remains in the encrypted snapshot; L1 private fields are durable.
foreach(['activities','resources'] as $key) { $zone->setAttribute($key,$this->publicDetails([$key=>$zone->$key ?? []])[$key]); }
$zone->setAttribute('editorial_draft',null);
foreach (['activities', 'resources'] as $key) {
$zone->setAttribute($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]);
}
$zone->setAttribute('editorial_draft', null);
}
public function reconcile(InvestmentZone $zone,int $working,int $public,int $actor): InvestmentZone
public function reconcile(InvestmentZone $zone, int $working, int $public, int $actor): InvestmentZone
{
$draft=$this->draft($zone);
abort_unless($draft && $working===(int)$draft['working_version'] && $public===(int)$zone->version_no,409,'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.');
abort_if((int)$draft['base_public_version']===$public,422,'A preparação já corresponde à versão pública atual.');
$draft['base_public_version']=$public;$draft['working_version']=$this->nextVersion($zone);$draft['actor_id']=$actor;$draft['prepared_at']=now()->toIso8601String();
DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]);
$this->snapshot($zone,$draft['working_version'],$actor,'profile_reconciled',['public'=>[...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],'draft'=>$draft]);
$draft = $this->draft($zone);
abort_unless($draft && $working === (int) $draft['working_version'] && $public === (int) $zone->version_no, 409, 'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.');
abort_if((int) $draft['base_public_version'] === $public, 422, 'A preparação já corresponde à versão pública atual.');
$draft['base_public_version'] = $public;
$draft['working_version'] = $this->nextVersion($zone);
$draft['actor_id'] = $actor;
$draft['prepared_at'] = now()->toIso8601String();
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_reconciled', ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'draft' => $draft]);
return $zone->fresh();
}
public function snapshot(InvestmentZone $zone,int $version,int $actor,string $action,array $data): void
/**
* @param array<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);
DB::table('investment_zone_revisions')->insert(['id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'version_no'=>$version,
'actor_id'=>$actor,'action'=>$action,'snapshot'=>Crypt::encryptString($json),'sha256'=>$hash]);
DB::table('audit_events')->insert(['source_system'=>'investment-zones','entity_type'=>'investment_zones','entity_id'=>$zone->id,
'actor_user_id'=>$actor,'action'=>$action,'after_data'=>json_encode(['version_no'=>$version,'sha256'=>$hash],JSON_THROW_ON_ERROR)]);
$json = json_encode($data, JSON_THROW_ON_ERROR);
$hash = hash('sha256', $json);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version,
'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]);
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version, 'sha256' => $hash], JSON_THROW_ON_ERROR)]);
}
}
+38 -24
View File
@@ -8,41 +8,55 @@ use Illuminate\Support\Facades\Validator;
class InvestmentZonePathContext
{
public const NOTICE='Referência para preparação; não constitui reserva de lote, confirmação de disponibilidade ou autorização de instalação.';
public const NOTICE = 'Referência para preparação; não constitui reserva de lote, confirmação de disponibilidade ou autorização de instalação.';
public function context(string $id,int $version): array
/**
* @return array<array-key, mixed>
*/
public function context(string $id, int $version): array
{
$zone=app(InvestmentZones::class)->visible()->where('category','industrial_park')->findOrFail($id);
abort_unless((int)$zone->version_no===$version,409,'A ficha pública mudou. Consulte a versão atual e confirme novamente; a seleção anterior não foi substituída.');
return ['zone_id'=>$zone->id,'consulted_public_version'=>(int)$zone->version_no,'title'=>$zone->name,'href'=>'/zonas/'.$zone->id,
'province'=>$zone->province,'district'=>$zone->district,'notice'=>self::NOTICE];
$zone = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->findOrFail($id);
abort_unless((int) $zone->version_no === $version, 409, 'A ficha pública mudou. Consulte a versão atual e confirme novamente; a seleção anterior não foi substituída.');
return ['zone_id' => $zone->id, 'consulted_public_version' => (int) $zone->version_no, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id,
'province' => $zone->province, 'district' => $zone->district, 'notice' => self::NOTICE];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
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);
$data=Validator::make($input,['zone_id'=>'required|uuid','consulted_public_version'=>'required|integer|min:1','version'=>'required|integer|min:1','confirm'=>'required|accepted'])->validate();
DB::transaction(function()use($actor,$id,$data){
$desk=app(VirtualDesk::class);$case=$desk->case($actor,$id,true,true);
abort_if($case->closed_at,409,'O dossier está encerrado.');
abort_unless(DB::table('investor_journeys')->where('id',$case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(),409,'Este dossier conserva outro roteiro.');
$payload=$desk->decode($case->payload);
foreach($payload['investor_path']['zone_contexts'] ?? [] as $existing){
if($existing['zone_id']===$data['zone_id'] && (int)$existing['consulted_public_version']===(int)$data['consulted_public_version']){return;}
abort_unless(app(InvestorPath::class)->available(), 404);
$data = Validator::make($input, ['zone_id' => 'required|uuid', 'consulted_public_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
$desk = app(VirtualDesk::class);
$case = $desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409, 'O dossier está encerrado.');
abort_unless(DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 409, 'Este dossier conserva outro roteiro.');
$payload = $desk->decode($case->payload);
foreach ($payload['investor_path']['zone_contexts'] ?? [] as $existing) {
if ($existing['zone_id'] === $data['zone_id'] && (int) $existing['consulted_public_version'] === (int) $data['consulted_public_version']) {
return;
}
}
abort_unless((int)$case->version===(int)$data['version'],409,'O dossier mudou; recupere a versão atual.');
abort_unless((int) $case->version === (int) $data['version'], 409, 'O dossier mudou; recupere a versão atual.');
// Lock the public record while checking the consulted version and recording its reference.
DB::table('investment_zones')->where('id',$data['zone_id'])->lockForUpdate()->firstOrFail();
$context=$this->context($data['zone_id'],(int)$data['consulted_public_version']);
abort_unless(count($payload['investor_path']['zone_contexts'] ?? [])<100,422,'Limite de referências deste dossier atingido.');
$payload['investor_path']['zone_contexts'][]=[...$context,'selected_at'=>now()->toIso8601String()];
DB::table('desk_cases')->where('id',$id)->update(['payload'=>$desk->encode($payload),'version'=>$case->version+1,'updated_at'=>now()]);
$desk->event($desk->case($actor,$id),$actor,'park_reference_selected',['zone_id'=>$context['zone_id'],'consulted_public_version'=>$context['consulted_public_version'],'scope'=>'preparation_reference_only']);
DB::table('investment_zones')->where('id', $data['zone_id'])->lockForUpdate()->firstOrFail();
$context = $this->context($data['zone_id'], (int) $data['consulted_public_version']);
abort_unless(count($payload['investor_path']['zone_contexts'] ?? []) < 100, 422, 'Limite de referências deste dossier atingido.');
$payload['investor_path']['zone_contexts'][] = [...$context, 'selected_at' => now()->toIso8601String()];
DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
$desk->event($desk->case($actor, $id), $actor, 'park_reference_selected', ['zone_id' => $context['zone_id'], 'consulted_public_version' => $context['consulted_public_version'], 'scope' => 'preparation_reference_only']);
});
}
}
@@ -3,8 +3,10 @@
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -12,79 +14,142 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
/** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class InvestmentZoneProfileInstallation
{
public const SOURCE='investment-zone-profile-installation';
public const SOURCE = 'investment-zone-profile-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Models/InvestmentZone.php','app/Services/InvestmentZones.php','app/Services/InvestmentZoneOperationalProfile.php',
'app/Services/InvestmentZonePathContext.php','app/Services/InvestmentZoneProfileInstallation.php','app/Http/Controllers/InvestmentZoneController.php',
'app/Http/Controllers/InvestorPathController.php','app/Services/InvestorPath.php','routes/investment-zones.php','routes/investor-path.php',
'resources/js/pages/investment/zone.tsx','resources/js/pages/investment/zones.tsx','resources/js/pages/investor-path/index.tsx',
'resources/js/pages/investor-path/show.tsx','resources/js/components/investment-zone-installation.tsx',
return ['app/Models/InvestmentZone.php', 'app/Services/InvestmentZones.php', 'app/Services/InvestmentZoneOperationalProfile.php',
'app/Services/InvestmentZonePathContext.php', 'app/Services/InvestmentZoneProfileInstallation.php', 'app/Http/Controllers/InvestmentZoneController.php',
'app/Http/Controllers/InvestorPathController.php', 'app/Services/InvestorPath.php', 'routes/investment-zones.php', 'routes/investor-path.php',
'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investment/zones.tsx', 'resources/js/pages/investor-path/index.tsx',
'resources/js/pages/investor-path/show.tsx', 'resources/js/components/investment-zone-installation.tsx',
'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php'];
}
public function fingerprint(): string
{
$files=array_map(fn($p)=>[$p,hash_file('sha256',base_path($p))],$this->files());
foreach(\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $file){$files[]=['public/build/'.$file->getRelativePathname(),hash_file('sha256',$file->getPathname())];}
usort($files,fn($a,$b)=>strcmp($a[0],$b[0]));
return hash('sha256',json_encode($files,JSON_THROW_ON_ERROR));
$files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files());
foreach (File::allFiles(public_path('build')) as $file) {
$files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
}
usort($files, fn ($a, $b) => strcmp($a[0], $b[0]));
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
private function authorize(User $actor,bool $write=false): void
private function authorize(User $actor, bool $write = false): void
{
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);}
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.');
if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
}
private function evidence(array $target,string $code): array
/**
* @param array<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')
&& config('database.connections.pgsql.host')==='127.0.0.1' && !config('database.connections.pgsql.url');
if($qa){return ['ready'=>true,'mode'=>'isolated_QA'];}
if(app()->environment('testing')){return ['ready'=>false,'mode'=>'invalid_QA'];}
$path=storage_path('app/private/investmoz-v2/park-profile/readiness.json');
$p=is_file($path)?json_decode(file_get_contents($path),true):[];$date=strtotime($p['verified_at'] ?? '');$auth=$p['active_authorization'] ?? [];
$ready=$target['database']==='investemoz' && (int)$target['port']===5432 && !config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'),['127.0.0.1','localhost'],true)
&& ($p['target']['database'] ?? '')===$target['database'] && (int)($p['target']['port'] ?? 0)===(int)$target['port']
&& ($p['code_hash'] ?? '')===$code && $date!==false && $date<=time() && $date>=time()-86400
$qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test')
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if ($qa) {
return ['ready' => true, 'mode' => 'isolated_QA'];
}
if (app()->environment('testing')) {
return ['ready' => false, 'mode' => 'invalid_QA'];
}
$path = storage_path('app/private/investmoz-v2/park-profile/readiness.json');
$p = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
$date = strtotime($p['verified_at'] ?? '');
$auth = $p['active_authorization'] ?? [];
$ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
&& ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port']
&& ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400
&& ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false)
&& ($p['restored_boot_verified'] ?? false) && (int)($p['restore_target']['port'] ?? 0)===55441 && str_ends_with($p['restore_target']['database'] ?? '','_test')
&& ($auth['decision_record'] ?? '')!=='' && ($auth['code_hash'] ?? '')===$code && ($auth['migration'] ?? '')===InvestmentZoneOperationalProfile::MIGRATION;
foreach(['dump','files'] as $kind){$file=$p[$kind.'_path'] ?? ''; $ready=$ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '',hash_file('sha256',$file));}
return ['ready'=>$ready,'mode'=>'active_requires_own_authorization_and_fresh_proof','proof_hash'=>is_file($path)?hash_file('sha256',$path):null];
&& ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test')
&& ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === InvestmentZoneOperationalProfile::MIGRATION;
foreach (['dump', 'files'] as $kind) {
$file = $p[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file));
}
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);$target=(array)DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');$code=$this->fingerprint();
$ledgerCount=DB::table('migrations')->where('migration',InvestmentZoneOperationalProfile::MIGRATION)->count();$ledger=$ledgerCount===1;$column=Schema::hasColumn('investment_zones','editorial_draft');
$shape=DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='editorial_draft'");
$correct=$shape && $shape->data_type==='text' && $shape->is_nullable==='YES';
$consistent=($ledger && $column && $correct) || ($ledgerCount===0 && !$column);$reasons=[];
if(!$consistent){$reasons[]='Ledger e coluna divergentes; reconciliar sem instalação parcial.';}
try{$this->authorize($actor,true);}catch(HttpException $e){$reasons[]=$e->getMessage();}
$evidence=$this->evidence($target,$code);if(!$evidence['ready']){$reasons[]='Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';}
$state=['target'=>$target,'migration'=>InvestmentZoneOperationalProfile::MIGRATION,'code_hash'=>$code,'installed'=>$ledger && $column,
'consistent'=>$consistent,'ready'=>$reasons===[],'reasons'=>$reasons,'evidence'=>$evidence];
return [...$state,'hash'=>hash_hmac('sha256',json_encode([$actor->id,session()->getId(),$state],JSON_THROW_ON_ERROR),config('app.key'))];
$this->authorize($actor);
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$code = $this->fingerprint();
$ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalProfile::MIGRATION)->count();
$ledger = $ledgerCount === 1;
$column = Schema::hasColumn('investment_zones', 'editorial_draft');
$shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='editorial_draft'");
$correct = $shape && $shape->data_type === 'text' && $shape->is_nullable === 'YES';
$consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
$reasons = [];
if (! $consistent) {
$reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.';
}
try {
$this->authorize($actor, true);
} catch (HttpException $e) {
$reasons[] = $e->getMessage();
}
$evidence = $this->evidence($target, $code);
if (! $evidence['ready']) {
$reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';
}
$state = ['target' => $target, 'migration' => InvestmentZoneOperationalProfile::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column,
'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
public function apply(User $actor,array $input): array
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{
$this->authorize($actor,true);
abort_if(array_diff(array_keys($input),['hash','idempotency_key','confirm','_token']),422,'Ator, migration ou caminho arbitrário não permitido.');
$data=Validator::make($input,['hash'=>'required|string|size:64','idempotency_key'=>'required|uuid','confirm'=>'required|accepted'])->validate();
return DB::transaction(function()use($actor,$data){
DB::select('SELECT pg_advisory_xact_lock(161026,130000)');$requestHash=hash('sha256',json_encode($data,JSON_THROW_ON_ERROR));
$old=DB::table('audit_events')->where('source_system',self::SOURCE)->where('correlation_id',$data['idempotency_key'])->first();
if($old){$receipt=json_decode($old->after_data,true);abort_unless((int)$old->actor_user_id===$actor->id && hash_equals($receipt['request_hash'],$requestHash),409,'Chave já utilizada com outro ator ou conteúdo.');return [...$receipt['result'],'replayed'=>true];}
$plan=$this->preview($actor);abort_unless(hash_equals($plan['hash'],$data['hash']),409,'Prévia alterada; consulte novamente.');abort_unless($plan['ready'],422,implode(' ',$plan['reasons']));
if(!$plan['installed']){
config(['investment-zone-profile.authenticated_installation'=>true]);
try{abort_unless(Artisan::call('migrate',['--path'=>'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php','--force'=>true])===0,500,'Falha na instalação nominal.');}
finally{config(['investment-zone-profile.authenticated_installation'=>false]);}
$this->authorize($actor, true);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::select('SELECT pg_advisory_xact_lock(161026,130000)');
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if ($old) {
$receipt = json_decode($old->after_data, true);
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.');
return [...$receipt['result'], 'replayed' => true];
}
abort_unless($this->preview($actor)['installed'],500,'Esquema nominal inconsistente.');
$result=['migration'=>$plan['migration'],'code_hash'=>$plan['code_hash'],'target'=>$plan['target'],'changed'=>!$plan['installed'],'business_records_changed'=>0,'replayed'=>false,'receipt'=>$data['idempotency_key']];
DB::table('audit_events')->insert(['source_system'=>self::SOURCE,'entity_type'=>'investment-zone-profile','entity_id'=>null,'actor_user_id'=>$actor->id,
'correlation_id'=>$data['idempotency_key'],'action'=>'schema','data_classification'=>'restricted','after_data'=>json_encode(['request_hash'=>$requestHash,'result'=>$result],JSON_THROW_ON_ERROR)]);
$plan = $this->preview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
if (! $plan['installed']) {
config(['investment-zone-profile.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.InvestmentZoneOperationalProfile::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.');
} finally {
config(['investment-zone-profile.authenticated_installation' => false]);
}
}
abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.');
$result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'investment-zone-profile', 'entity_id' => null, 'actor_user_id' => $actor->id,
'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
+20 -14
View File
@@ -97,15 +97,16 @@ class InvestmentZones
return DB::transaction(function () use ($id, $data, $actor) {
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
if ($id && $zone->category === 'industrial_park') {
$prepared=app(InvestmentZoneOperationalProfile::class)->prepare($zone,$data,$actor);
$this->storePendingLocations($zone,$data['locations'] ?? [],$actor);
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
$this->storePendingLocations($zone, $data['locations'] ?? [], $actor);
return $prepared;
}
abort_if($id && (int) $zone->version_no !== (int) $data['version_no'], 409, 'Versão alterada. Recarregue antes de guardar.');
$version = $id ? (int) $zone->version_no + 1 : 1;
$locations = $data['locations'] ?? [];
unset($data['locations'], $data['version_no']);
abort_if(array_key_exists('operational_details',$data),422,'Prepare L1 numa ficha de parque existente; não cria outro cadastro.');
abort_if(array_key_exists('operational_details', $data), 422, 'Prepare L1 numa ficha de parque existente; não cria outro cadastro.');
$zone->fill([...$data, 'editor_id' => $actor, 'reviewer_id' => null, 'published_at' => null,
'version_no' => $version, 'publication_status' => 'draft', 'validation_status' => 'pending'])->save();
foreach ($locations as $location) {
@@ -127,8 +128,11 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class);
if ($zone->category === 'industrial_park') { $profile->promote($zone,$version); }
else { abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); }
if ($zone->category === 'industrial_park') {
$profile->promote($zone, $version);
} else {
abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.');
}
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->lockForUpdate()->first();
$evidence = $zone->source_evidence ?? [];
$officialEvidence = collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional'], true)
@@ -166,8 +170,8 @@ class InvestmentZones
{
return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile=app(InvestmentZoneOperationalProfile::class);
abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int)$zone->version_no) !== $version, 409, 'Versão alterada.');
$profile = app(InvestmentZoneOperationalProfile::class);
abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save();
$this->snapshot($zone, $actor, 'suspended');
@@ -201,7 +205,7 @@ class InvestmentZones
private function snapshot(InvestmentZone $zone, int $actor, string $action): void
{
$json = json_encode([...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],JSON_THROW_ON_ERROR);
$json = json_encode([...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], JSON_THROW_ON_ERROR);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id,
'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action,
'snapshot' => Crypt::encryptString($json), 'sha256' => hash('sha256', $json)]);
@@ -210,14 +214,16 @@ class InvestmentZones
'after_data' => json_encode(['version_no' => $zone->version_no, 'sha256' => hash('sha256', $json)], JSON_THROW_ON_ERROR)]);
}
/** Existing explicit territorial control: new associations remain pending, never promoted with the profile. */
private function storePendingLocations(InvestmentZone $zone,array $locations,int $actor): void
/** Existing explicit territorial control: new associations remain pending, never promoted with the profile.
* @param array<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([
'id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'geo_location_id'=>$location['geo_location_id'],
'scope_type'=>$location['scope_type'] ?? 'administrative','source_reference'=>$location['source_reference'],
'legal_source_id'=>$location['legal_source_id'] ?? null,'validation_status'=>'pending','editor_id'=>$actor,'version_no'=>1,'created_at'=>now(),'updated_at'=>now(),
'id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'geo_location_id' => $location['geo_location_id'],
'scope_type' => $location['scope_type'] ?? 'administrative', 'source_reference' => $location['source_reference'],
'legal_source_id' => $location['legal_source_id'] ?? null, 'validation_status' => 'pending', 'editor_id' => $actor, 'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
]);
}
}
+175 -70
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
@@ -27,63 +28,83 @@ final class InvestorContext
public function __construct(private readonly VirtualDesk $desk) {}
/**
* @return array<array-key, mixed>|null
*/
public function draft(User $actor, string $id): ?array
{
$case = $this->desk->case($actor, $id, true);
$draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string)$actor->id] ?? null;
return $draft ? [...$draft, 'needs_review' => (int)$draft['version'] !== (int)$case->version] : null;
$draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string) $actor->id] ?? null;
return $draft ? [...$draft, 'needs_review' => (int) $draft['version'] !== (int) $case->version] : null;
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function saveDraft(User $actor, string $id, array $input): array
{
$case=$this->desk->case($actor,$id,true);
abort_if($case->closed_at,409);
$sequence = (int)($input['draft_sequence'] ?? 0);
$entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:200','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[];
$case = $this->desk->case($actor, $id, true);
abort_if($case->closed_at, 409);
$sequence = (int) ($input['draft_sequence'] ?? 0);
$entrySources = Validator::make($input, ['entry_sources' => 'sometimes|array|max:200', 'entry_sources.*' => 'string|max:100'])->validate()['entry_sources'] ?? [];
unset($input['draft_sequence'],$input['entry_sources']);
abort_unless($sequence > 0, 422);
$data = $this->input($input);
if (isset($data['complements'])) app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
foreach ($data['selection'] as $slot=>$key) {
if (!$key) continue;
$ref=$this->reference($key);
abort_unless(in_array($ref['kind'],$slot==='opportunity'?['opportunity','business']:[$slot],true),422,'Referência incompatível com a escolha.');
if (isset($data['complements'])) {
app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
}
foreach ($data['selection'] as $slot => $key) {
if (! $key) {
continue;
}
$ref = $this->reference($key);
if (! (in_array($ref['kind'], $slot === 'opportunity' ? ['opportunity', 'business'] : [$slot], true))) {
abort(422, 'Referência incompatível com a escolha.');
}
}
foreach ($data['opportunity_choices'] ?? [] as $choice) {
$ref = $this->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422);
$record = Opportunity::findOrFail($ref['id']);
$record = Opportunity::whereKey($ref['id'])->firstOrFail();
app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id);
$key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record);
if (!empty($choice['territory'])) {
if (! empty($choice['territory'])) {
$location = $this->reference($choice['territory']);
abort_unless($location['kind'] === 'territory', 422);
if ($key) abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.');
}
}
if (isset($data['opportunity_choices'])) app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $this->state($case)['opportunity_choices'] ?? [], true);
if (!empty($data['selection']['park'])) {
$first=$data['opportunity_choices'][0] ?? null;
$source=$first['source'] ?? $data['selection']['opportunity'] ?? null;
if ($source && str_starts_with($source,'opportunity:')) {
$key=$first['corridor_key'] ?? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail(substr($source,12)));
if ($key) {
$territory=$first['territory'] ?? $data['selection']['territory'] ?? null;
$ids=app(InvestmentCorridorContext::class)->provinces($key);
$territory=$territory ? substr($territory,10) : (count($ids)===1?$ids[0]->id:null);
$park=$this->reference($data['selection']['park']);
abort_unless($park['kind']==='park' && in_array($park['id'],array_column(app(InvestmentCorridorContext::class)->parks($key,$territory),'id'),true),422,'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.');
}
}
}
if (isset($data['opportunity_choices'])) {
app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $this->state($case)['opportunity_choices'] ?? [], true);
}
if (! empty($data['selection']['park'])) {
$first = $data['opportunity_choices'][0] ?? null;
$source = $first['source'] ?? $data['selection']['opportunity'] ?? null;
if ($source && str_starts_with($source, 'opportunity:')) {
$key = $first['corridor_key'] ?? app(InvestmentCorridorContext::class)->key(Opportunity::whereKey(substr($source, 12))->firstOrFail());
if ($key) {
$territory = $first['territory'] ?? $data['selection']['territory'] ?? null;
$ids = app(InvestmentCorridorContext::class)->provinces($key);
$territory = $territory ? substr($territory, 10) : (count($ids) === 1 ? $ids[0]->id : null);
$park = $this->reference($data['selection']['park']);
abort_unless($park['kind'] === 'park' && in_array($park['id'], array_column(app(InvestmentCorridorContext::class)->parks($key, $territory), 'id'), true), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
}
}
}
return DB::transaction(function () use ($actor, $id, $data, $sequence, $entrySources) {
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at || (int)$case->version !== (int)$data['version'], 409, 'O dossier mudou. Recupere os dados antes de continuar.');
abort_if($case->closed_at || (int) $case->version !== (int) $data['version'], 409, 'O dossier mudou. Recupere os dados antes de continuar.');
$payload = $this->desk->decode($case->payload);
$previous = $payload['investor_path']['context_drafts'][(string)$actor->id] ?? [];
$previous = $payload['investor_path']['context_drafts'][(string) $actor->id] ?? [];
abort_if(($previous['draft_sequence'] ?? 0) > $sequence, 409, 'Existe um rascunho mais recente. Recupere o dossier antes de continuar.');
$payload['investor_path']['context_drafts'][(string)$actor->id] = [...$data, 'draft_sequence' => $sequence, 'entry_sources'=>array_values(array_unique([...($previous['entry_sources']??[]),...$entrySources]))];
$payload['investor_path']['context_drafts'][(string) $actor->id] = [...$data, 'draft_sequence' => $sequence, 'entry_sources' => array_values(array_unique([...($previous['entry_sources'] ?? []), ...$entrySources]))];
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload)]);
return ['saved' => true];
});
}
@@ -93,6 +114,9 @@ final class InvestorContext
return Schema::hasTable('investor_navigation_preferences');
}
/**
* @return array<array-key, mixed>
*/
public function state(\stdClass $case): array
{
$payload = $this->desk->decode($case->payload);
@@ -112,7 +136,9 @@ final class InvestorContext
}
}
}
if (!empty($context['opportunity_choices']) && !array_key_exists('park', $context['opportunity_choices'][0])) $context['opportunity_choices'][0]['park'] = $context['selection']['park'] ?? null;
if (! empty($context['opportunity_choices']) && ! array_key_exists('park', $context['opportunity_choices'][0])) {
$context['opportunity_choices'][0]['park'] = $context['selection']['park'] ?? null;
}
$zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
@@ -120,13 +146,22 @@ final class InvestorContext
return $context;
}
/**
* @param array<array-key, mixed> $selection
*/
public function dependencyHash(array $selection): string
{
unset($selection['portfolio'], $selection['selection']['portfolio']);
if (isset($selection['opportunity_choices'])) $selection['opportunity_choices'] = array_map(fn($choice)=>Arr::except($choice,['portfolio_origins']),$selection['opportunity_choices']);
if (isset($selection['opportunity_choices'])) {
$selection['opportunity_choices'] = array_map(fn ($choice) => Arr::except($choice, ['portfolio_origins']), $selection['opportunity_choices']);
}
return hash('sha256', json_encode(Arr::sortRecursive($selection), JSON_THROW_ON_ERROR));
}
/**
* @return array<array-key, mixed>
*/
public function reference(string $key): array
{
[$kind,$id] = array_pad(explode(':', $key, 2), 2, '');
@@ -140,7 +175,7 @@ final class InvestorContext
$zoneAnalysis = null;
if ($kind === 'opportunity') {
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.');
$o = Opportunity::with('sector')->findOrFail((int) $id);
$o = Opportunity::with('sector')->whereKey((int) $id)->firstOrFail();
$published = Opportunity::published()->whereKey($o->id)->exists();
if (! $published) {
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.');
@@ -160,7 +195,9 @@ final class InvestorContext
$fields['sector'] = $o->sector->name;
}
$proactive = $published ? app(ProactiveInvestment::class)->publicData($o) : ($draft['proactive'] ?? null);
if ($proactive) $zoneAnalysis = ['proactive' => $proactive, 'source_key' => $key, 'source_version' => $version, 'private_source' => !$published];
if ($proactive) {
$zoneAnalysis = ['proactive' => $proactive, 'source_key' => $key, 'source_version' => $version, 'private_source' => ! $published];
}
// Text labels do not establish a canonical territorial correspondence.
if ($o->province) {
$fields['province'] = $o->province;
@@ -173,7 +210,7 @@ final class InvestorContext
}
} elseif ($kind === 'portfolio') {
abort_unless(Str::isUuid($id), 422);
$dossier = \App\Http\Controllers\InvestmentPortfolioController::visible()->findOrFail($id);
$dossier = InvestmentPortfolioController::visible()->whereKey($id)->firstOrFail();
$title = $dossier->title;
$href = '/carteira/'.$id;
// Documentary coverage is not the investor's chosen implantation.
@@ -185,7 +222,7 @@ final class InvestorContext
'geography' => app(InvestmentRelations::class)->dossier($dossier), 'available_opportunity' => false];
} elseif ($kind === 'business') {
abort_unless(config('existing-business.enabled') && Str::isUuid($id), 404);
$o = ExistingBusiness::published()->with('sector')->findOrFail($id);
$o = ExistingBusiness::published()->with('sector')->whereKey($id)->firstOrFail();
$title = $o->public_title;
$href = '/negocios/'.$o->public_slug;
$version = $o->version_no;
@@ -198,7 +235,9 @@ final class InvestorContext
if ($location) {
$leaf = $location->province_id;
foreach (['district', 'admin_post', 'locality'] as $level) {
if (in_array($location->precision_level, array_slice(['province', 'district', 'admin_post', 'locality', 'exact'], array_search($level, ['province', 'district', 'admin_post', 'locality', 'exact'])), true) && $location->{$level.'_id'}) {
if (in_array($location->precision_level, match ($level) {
'district' => ['district', 'admin_post', 'locality', 'exact'], 'admin_post' => ['admin_post', 'locality', 'exact'], 'locality' => ['locality', 'exact']
}, true) && $location->{$level.'_id'}) {
$leaf = $location->{$level.'_id'};
}
}
@@ -208,7 +247,7 @@ final class InvestorContext
}
} elseif ($kind === 'sector') {
abort_unless(ctype_digit($id), 422);
$s = Sector::where('is_active', true)->findOrFail((int) $id);
$s = Sector::where('is_active', true)->whereKey((int) $id)->firstOrFail();
$title = $s->name;
$href = SectorOrganization::href($s->slug);
$version = $s->updated_at?->toIso8601String();
@@ -216,14 +255,14 @@ final class InvestorContext
} elseif ($kind === 'territory') {
abort_unless(Str::isUuid($id), 422);
$t = app(InvestmentGeography::class)->publicReferences()->whereIn('location_type', array_values(InvestmentGeography::FIELDS))->where('id', $id)->first();
abort_unless($t, 404, 'Território público indisponível.');
abort_unless($t !== null, 404, 'Território público indisponível.');
$title = $t->name;
$version = $t->version_no;
$href = '/oportunidades?'.$t->location_type.'_id='.$id;
$fields = $this->territoryFields($id);
} elseif ($kind === 'park') {
abort_unless(Str::isUuid($id), 422);
$p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id);
$p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->whereKey($id)->firstOrFail();
$title = $p->name;
$fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category];
$version = $p->version_no;
@@ -249,6 +288,9 @@ final class InvestorContext
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
}
/**
* @return array<array-key, mixed>
*/
private function territoryFields(string $id): array
{
$path = app(InvestmentGeography::class)->path($id);
@@ -271,6 +313,9 @@ final class InvestorContext
return $fields;
}
/**
* @return array<array-key, mixed>
*/
public function options(string $query = ''): array
{
$query = mb_substr(trim($query), 0, 120);
@@ -296,6 +341,9 @@ final class InvestorContext
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
}
/**
* @return array<array-key, mixed>
*/
public function current(User $actor): array
{
if (! $this->installed()) {
@@ -322,6 +370,10 @@ final class InvestorContext
return ['installed' => true, 'focus' => ['case_id' => $case->id, 'step_code' => $p->step_code, 'version' => (int) $p->version]];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function focus(User $actor, array $input): array
{
abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.');
@@ -344,9 +396,13 @@ final class InvestorContext
});
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
private function input(array $input): array
{
abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.');
abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']) !== [], 422, 'Campo de controlo desconhecido.');
abort_if(! app(PrivateInvestmentPreparation::class)->available() && (! empty($input['document_links']) || array_intersect(array_keys(is_array($input['declarations'] ?? null) ? $input['declarations'] : []), PrivateInvestmentPreparation::FIELDS)), 409, 'Esta preparação ainda não está disponível neste ambiente. As respostas foram preservadas.');
@@ -375,6 +431,10 @@ final class InvestorContext
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function preview(User $actor, string $id, array $input): array
{
$data = $this->input($input);
@@ -397,7 +457,7 @@ final class InvestorContext
if (! array_key_exists('opportunity_choices', $data) && array_key_exists('territory', $data['selection'])) {
$source = $data['selection']['opportunity'] ?? ($old['selection']['opportunity']['key'] ?? null);
if ($source && str_starts_with($source, 'opportunity:')) {
$documented = Opportunity::findOrFail(substr($source, 12))->primary_territory_id;
$documented = Opportunity::whereKey(substr($source, 12))->firstOrFail()->primary_territory_id;
abort_if($documented && $data['selection']['territory'] !== 'territory:'.$documented, 422,
'Confirme a alternativa territorial por ficha e indique o motivo; a localização documentada foi preservada.');
}
@@ -407,15 +467,21 @@ final class InvestorContext
$data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null;
// Shared fields concern the first identified opportunity; other sites remain separate.
$data['selection']['territory'] = $choices[0]['territory']['key'] ?? null;
if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) $data['selection']['park'] = $choices[0]['park']['key'] ?? null;
if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) {
$data['selection']['park'] = $choices[0]['park']['key'] ?? null;
}
} elseif (array_key_exists('opportunity', $data['selection']) && $choices !== []) {
abort(422, 'Actualize a selecção identificada de oportunidades; não substitua silenciosamente o conjunto por uma ficha.');
}
$selection = $old['selection'];
foreach ($complements as $complement) {
if (($complement['availability'] ?? '') === 'review_required') continue;
if (($complement['availability'] ?? '') === 'review_required') {
continue;
}
foreach ([$complement['origin']['key'], $complement['target']['key'] ?? ''] as $key) {
if (str_starts_with($key, 'opportunity:')) abort_unless(in_array($key, array_column(array_column($choices, 'opportunity'), 'key'), true), 422, 'Conserve a oportunidade escolhida ou retire explicitamente a sua relação complementar.');
if (str_starts_with($key, 'opportunity:')) {
abort_unless(in_array($key, array_column(array_column($choices, 'opportunity'), 'key'), true), 422, 'Conserve a oportunidade escolhida ou retire explicitamente a sua relação complementar.');
}
}
}
foreach ($data['selection'] as $slot => $key) {
@@ -426,16 +492,20 @@ final class InvestorContext
}
$ref = $this->reference($key);
$expected = $slot === 'opportunity' ? ['opportunity', 'business'] : [$slot];
abort_unless(in_array($ref['kind'], $expected, true), 422, 'Referência incompatível com a dimensão.');
if (! (in_array($ref['kind'], $expected, true))) {
abort(422, 'Referência incompatível com a dimensão.');
}
$selection[$slot] = $ref;
}
$corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail($selection['opportunity']['id'])) : null);
if ($corridor && isset($selection['territory'])) abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.');
? app(InvestmentCorridorContext::class)->key(Opportunity::whereKey($selection['opportunity']['id'])->firstOrFail()) : null);
if ($corridor && isset($selection['territory'])) {
abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.');
}
if ($corridor && isset($selection['park'])) {
$eligible = array_column(app(InvestmentCorridorContext::class)->parks($corridor, $selection['territory']['id'] ?? null), 'id');
if (!in_array($selection['park']['id'], $eligible, true)) {
abort_if(!empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
if (! in_array($selection['park']['id'], $eligible, true)) {
abort_if(! empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
unset($selection['park']);
}
}
@@ -484,14 +554,16 @@ final class InvestorContext
}
}
if ($corridor) {
foreach (['province_id','district_id','admin_post_id','locality_id','province','district','location'] as $key) {
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'] as $key) {
if (isset($selection['territory']['fields'][$key])) {
$fields[$key]=(string)$selection['territory']['fields'][$key];
$provenance[$key]=Arr::except($selection['territory'],['fields']);
} else unset($fields[$key],$provenance[$key]);
$fields[$key] = (string) $selection['territory']['fields'][$key];
$provenance[$key] = Arr::except($selection['territory'], ['fields']);
} else {
unset($fields[$key],$provenance[$key]);
}
}
$fields['special_zone_id']=$selection['park']['id']??'';
$fields['investment_regime']=$selection['park']['fields']['investment_regime']??'general';
$fields['special_zone_id'] = $selection['park']['id'] ?? '';
$fields['investment_regime'] = $selection['park']['fields']['investment_regime'] ?? 'general';
}
$replaced = [];
foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) {
@@ -505,11 +577,16 @@ final class InvestorContext
}
}
$changedFields = array_column($replaced, 'field');
if ($complements !== ($old['complements'] ?? [])) $changed[] = 'complements';
if ($complements !== ($old['complements'] ?? [])) {
$changed[] = 'complements';
}
$choicesChanged = $choices !== ($old['opportunity_choices'] ?? []);
$choiceInputsChanged = array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$choices) !== array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$old['opportunity_choices']??[]);
if ($choiceInputsChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
elseif ($choicesChanged) $changed[] = 'portfolio_origins';
$choiceInputsChanged = array_map(fn ($c) => Arr::except($c, ['portfolio_origins']), $choices) !== array_map(fn ($c) => Arr::except($c, ['portfolio_origins']), $old['opportunity_choices'] ?? []);
if ($choiceInputsChanged) {
$changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
} elseif ($choicesChanged) {
$changed[] = 'portfolio_origins';
}
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
$dependencyChanged = $choiceInputsChanged || in_array('complements', $changed, true) || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
@@ -524,7 +601,9 @@ final class InvestorContext
if ($privateConditionsChanged) {
$steps = array_values(array_unique([...$steps, 'S02', 'S05', 'S06', 'S07']));
}
if (in_array('complements', $changed, true)) $steps = array_values(array_unique([...$steps, 'S02', 'S08']));
if (in_array('complements', $changed, true)) {
$steps = array_values(array_unique([...$steps, 'S02', 'S08']));
}
$privatePreparation = null;
if (app(PrivateInvestmentPreparation::class)->available()) {
$docs = $this->preparationDocuments($id);
@@ -543,6 +622,10 @@ final class InvestorContext
return [...$plan, 'preview_hash' => $hash];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function commit(User $actor, string $id, array $input): array
{
abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.');
@@ -586,7 +669,7 @@ final class InvestorContext
}
}
$payload['investor_path']['context'] = $context;
unset($payload['investor_path']['context_drafts'][(string)$actor->id]);
unset($payload['investor_path']['context_drafts'][(string) $actor->id]);
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'title' => $context['fields']['project_title'] ?? $case->title, 'version' => $case->version + 1, 'updated_at' => now()]);
$this->desk->event($this->desk->case($actor, $id), $actor, 'investment_context_changed', ['before' => $before, 'after' => $context, 'affected_result_ids' => array_column($plan['dependent_results'], 'id'), 'preserved_request_ids' => array_column($plan['requests'], 'id')]);
$result = ['case_id' => $id, 'version' => (int) $case->version + 1, 'revision' => $revision, 'replayed' => false];
@@ -596,6 +679,9 @@ final class InvestorContext
}, 3);
}
/**
* @return array<array-key, mixed>
*/
public function view(User $actor, string $id, ?string $opportunity = null): array
{
$case = $this->desk->case($actor, $id);
@@ -622,21 +708,28 @@ final class InvestorContext
$prefill = $this->prefill($ctx, $opportunity);
$saved = $this->desk->decode($case->payload);
if (!empty($saved['reuse_consent'])) {
$contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== '');
if (! empty($saved['reuse_consent'])) {
$contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn ($v) => is_string($v) && $v !== '');
$prefill['simulator'] = [...$contacts, ...$prefill['simulator']];
$prefill['land'] = [...$contacts, ...(!empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']];
$prefill['land'] = [...$contacts, ...(! empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']];
}
return ['private_investment' => $preparation, 'preparation_documents' => $this->preparationDocuments($id), 'simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']),
'can_write' => $path['canWrite'], 'next' => $path['next'], 'results' => $path['results'], 'requests' => $path['requests'],
'prefill' => $prefill, 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
}
/**
* @return array<array-key, mixed>
*/
public function preparationDocuments(string $id): array
{
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all();
}
/**
* @return array<array-key, mixed>
*/
public function landPreparation(\stdClass $case, ?string $opportunity = null): array
{
$payload = $this->desk->decode($case->payload);
@@ -648,6 +741,10 @@ final class InvestorContext
return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $key ? ($path['opportunity_fact_reasons'][$key]['land_needed'] ?? '') : ($path['fact_reasons']['land_needed'] ?? '')];
}
/**
* @param array<array-key, mixed> $ctx
* @return array<array-key, mixed>
*/
public function prefill(array $ctx, ?string $opportunity = null): array
{
$original = $ctx;
@@ -659,15 +756,21 @@ final class InvestorContext
$sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular'];
if (($ctx['land_preparation']['need'] ?? null) === 'yes') {
$tenure = ['duat' => ['duat', 'DUAT'], 'lease' => ['leased', 'Arrendado']][$f['site_tenure'] ?? ''] ?? null;
if ($tenure) [$sim['land_status'], $sim['land_status_label']] = $tenure;
if ($tenure) {
[$sim['land_status'], $sim['land_status_label']] = $tenure;
}
}
// Human labels travel with the exact existing identifiers, not inferred locations.
foreach (app(InvestmentGeography::class)->publicReferences()->whereIn('id', array_values(Arr::only($f, array_keys(InvestmentGeography::FIELDS))))->get(['id', 'name']) as $place) {
foreach (array_keys(InvestmentGeography::FIELDS) as $key) {
if (($f[$key] ?? null) === $place->id) $sim[$key.'_label'] = $place->name;
if (($f[$key] ?? null) === $place->id) {
$sim[$key.'_label'] = $place->name;
}
}
}
if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->find($f['sector_id']))) $sim['sector_id_label'] = $sector->name;
if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->whereKey($f['sector_id'])->first())) {
$sim['sector_id_label'] = $sector->name;
}
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
@@ -675,12 +778,13 @@ final class InvestorContext
'portfolio_origin' => $ctx['selection']['portfolio'] ?? null,
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
// Retain dossier options as provenance when no consumer is selected; scoped fields remain empty.
'opportunity_choices' => ($ctx['opportunity_choices'] ?? []) ?: ($original['opportunity_choices'] ?? []),
'complements' => $ctx['complements'] ?? [],
'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
'path' => $ctx['path_applicability'] ?? null,
'land_preparation' => $ctx['land_preparation'] ?? null,
'field_provenance' => array_map(fn($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []),
'field_provenance' => array_map(fn ($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []),
'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
@@ -688,6 +792,7 @@ final class InvestorContext
$scoped = app(InvestmentConsumerScope::class)->context($original, $opportunity);
$f = $scoped['fields'];
return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null),
'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null),
'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
+6 -3
View File
@@ -20,7 +20,7 @@ final class InvestorContextConsumers
}
}
return 0;
return 0;
}
public function landRevision(string $id): int
@@ -32,7 +32,7 @@ return 0;
}
}
return 0;
return 0;
}
public function submissionRevision(string $id): int
@@ -44,9 +44,12 @@ return 0;
}
}
return 0;
return 0;
}
/**
* @param array<array-key, mixed> $input
*/
public function assertReviewed(User $actor, string $id, array $input, int $bound): int
{
$case = app(VirtualDesk::class)->case($actor, $id, true, true);
+14 -3
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
@@ -15,6 +16,9 @@ final class InvestorContextInstallation
{
public const SOURCE = 'investor-context-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Http/Controllers/InvestmentSubmissionController.php', 'app/Http/Controllers/InvestorContextController.php', 'app/Http/Controllers/LandAssistanceController.php', 'app/Http/Controllers/PublicPortalController.php', 'app/Http/Controllers/VirtualDeskController.php', 'app/Http/Requests/StoreLandAssistanceCaseRequest.php', 'app/Services/InvestorContext.php', 'app/Services/InvestorContextConsumers.php', 'app/Services/InvestorContextInstallation.php', 'app/Services/InvestorPath.php', 'app/Services/VirtualDesk.php', 'database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php', 'resources/js/app.tsx', 'resources/js/components/catalogue-navigation.tsx', 'resources/js/components/dossier-navigation-context.tsx', 'resources/js/components/dossier-prefill.tsx', 'resources/js/components/navigation-context.tsx', 'resources/js/components/portal-grouped-menu.tsx', 'resources/js/components/simulation-form.tsx', 'resources/js/layouts/app/app-sidebar-layout.tsx', 'resources/js/layouts/portal-navigation.tsx', 'resources/js/lib/investor-context.ts', 'resources/js/lib/investor-path.ts', 'resources/js/lib/portal-navigation.ts', 'resources/js/pages/desk/request.tsx', 'resources/js/pages/investment/business-opportunity.tsx', 'resources/js/pages/investment/catalogue.tsx', 'resources/js/pages/investment/detail.tsx', 'resources/js/pages/investment/submission.tsx', 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investor-path/context-installation.tsx', 'resources/js/pages/investor-path/context-start.tsx', 'resources/js/pages/investor-path/context.tsx', 'resources/js/pages/investor-path/show.tsx', 'resources/js/pages/land-assistance/form.tsx', 'resources/js/pages/portal/services.tsx', 'resources/js/pages/portal/why-invest.tsx', 'routes/investment.php', 'routes/investor-path.php', 'routes/web.php'];
@@ -41,6 +45,9 @@ final class InvestorContextInstallation
}
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);
@@ -60,12 +67,12 @@ final class InvestorContextInstallation
}
$isolated = app()->environment('testing') && config('database.connections.pgsql.host') === '127.0.0.1' && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') && ! config('database.connections.pgsql.url');
$proofPath = storage_path('app/private/investmoz-v2/investor-context/readiness.json');
$proof = is_file($proofPath) ? json_decode(file_get_contents($proofPath), true) : [];
$proof = is_file($proofPath) ? json_decode(CheckedFile::read($proofPath), true) : [];
$fresh = ! app()->environment('testing') && ($proof['code_hash'] ?? '') === $code && ($proof['target'] ?? []) === $target && ($proof['authorization_record'] ?? '') !== '' && ($proof['migration'] ?? '') === InvestorContext::MIGRATION
&& ($proof['restore_verified'] ?? false) && ($proof['restored_boot_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time();
foreach (['dump', 'files'] as $kind) {
$p = $proof[$kind.'_path'] ?? '';
$fresh = $fresh && is_file($p) && hash_equals($proof[$kind.'_sha256'] ?? '', hash_file('sha256', $p));
$fresh = $fresh && is_file($p) && hash_equals($proof[$kind.'_sha256'] ?? '', CheckedFile::sha256($p));
}
if (! $isolated && ! $fresh) {
$reasons[] = 'Autorização específica, hashes e backup/restauro/arranque frescos desta candidata necessários.';
@@ -75,10 +82,14 @@ final class InvestorContextInstallation
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
abort_if(array_diff(array_keys($input), ['hash', 'operation_key', 'confirm', '_token']), 422, 'Migration, ator ou caminho arbitrário recusado.');
abort_if(array_diff(array_keys($input), ['hash', 'operation_key', 'confirm', '_token']) !== [], 422, 'Migration, ator ou caminho arbitrário recusado.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) {
+8 -4
View File
@@ -6,6 +6,7 @@ use App\Models\CrmTask;
use App\Models\LandAssistanceCase;
use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -152,7 +153,7 @@ final class InvestorPath
$landCase = (int) $case->user_id === $actor->id
? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get()
->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id
&& (!$allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null;
&& (! $allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null;
$preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null;
if ($preparation && $preparation['enabled']) {
@@ -163,7 +164,7 @@ final class InvestorPath
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect($path['sector_contexts'] ?? [])->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect($path['content_sources'] ?? [])->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null,
'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect(StructuredData::collectionItems($path['sector_contexts'] ?? []))->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect(StructuredData::collectionItems($path['content_sources'] ?? []))->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null,
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
@@ -256,8 +257,11 @@ final class InvestorPath
abort_if($state === 'completed' && ($step['context_review_required'] ?? false) && (! (bool) ($data['context_reviewed'] ?? false) || ($data['context_revision'] ?? -1) !== ($view['investment_context']['revision'] ?? 0)), 422, 'O contexto desta etapa exige nova conferência fundamentada; o registo anterior permanece no histórico.');
$detail = [...$data, 'context_dependency_hash' => $view['investment_context']['dependency_hash'] ?? null, 'fintech_fingerprint' => $view['fintech']['review_fingerprints'][$code] ?? null, 'scope' => $scope, 'actor_id' => $actor->id, 'recorded_at' => now()->toIso8601String()];
$stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail'));
if ($key) $stored['opportunities'][$key] = $detail;
else $stored = [...$stored, ...$detail];
if ($key) {
$stored['opportunities'][$key] = $detail;
} else {
$stored = [...$stored, ...$detail];
}
DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored),
'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'),
'version_no' => $step['version'] + 1, 'updated_at' => now()]);
+31 -14
View File
@@ -5,13 +5,18 @@ namespace App\Services;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Support\CheckedPattern;
use DOMDocument;
use DOMElement;
use DOMNode;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** A reading adapter over the existing library, never a second content/opportunity register. */
final class LegacyContent
@@ -68,13 +73,13 @@ final class LegacyContent
$opportunities = Opportunity::published()->whereIn('sector_id', $relatedSectors->pluck('id'))->latest('published_at')->limit(12)->get(['id', 'title', 'slug', 'province', 'summary']);
}
$localHtml = $this->localLinks($this->sanitize($source['content_html']));
$editorial = \Illuminate\Support\Facades\Schema::hasColumn('portal_resources', 'editorial_content')
$editorial = Schema::hasColumn('portal_resources', 'editorial_content')
? DB::table('portal_resources')->where('id', $row->resource_id)->value('editorial_content') : null;
if ($editorial) {
$adaptation = json_decode($editorial, true, 64, JSON_THROW_ON_ERROR);
$localHtml = implode('', array_map(fn ($p) => '<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;
if (!empty($adaptation['image_resource_id'])) {
if (! empty($adaptation['image_resource_id'])) {
$image = $this->publicResources()->where('r.id', $adaptation['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->first();
}
}
@@ -86,11 +91,15 @@ final class LegacyContent
$relatedArticles = [];
if ($includeRelated) {
foreach ($this->related($identity['group'], $slug) as $item) {
if (in_array($item['href'], $alreadyLinked, true)) continue;
if (in_array($item['href'], $alreadyLinked, true)) {
continue;
}
try {
$relatedArticles[] = $this->article($item['slug'], false);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) {
if ($error->getStatusCode() !== 404) throw $error;
} catch (HttpException $error) {
if ($error->getStatusCode() !== 404) {
throw $error;
}
}
}
}
@@ -125,16 +134,21 @@ final class LegacyContent
&& str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) {
return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.';
}
preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs);
foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320);
if (preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs) === false) {
throw new \RuntimeException('Invalid paragraph encoding; original preserved.');
}
foreach ([...$paragraphs[1], $html] as $paragraph) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) >= 60) {
return Str::limit($text, 320);
}
}
return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Resolve a library identity, preserving the already stored source URLs and versions. */
/** @return array<string, mixed> */
/** Resolve a library identity, preserving the already stored source URLs and versions.
* @return array<string, mixed> */
public function articleForResource(int $resourceId): array
{
abort_unless($this->available(), 404);
@@ -239,8 +253,10 @@ final class LegacyContent
});
}
/** Resolve explicit transport aliases without changing original IDs, versions or relations. */
private function identityResources(): \Illuminate\Support\Collection
/** Resolve explicit transport aliases without changing original IDs, versions or relations.
* @return Collection<int, \stdClass>
*/
private function identityResources(): Collection
{
$identities = array_column(config('legacy_content.articles', []), 'external_id');
$aliases = config('legacy_content_aliases', []);
@@ -269,6 +285,7 @@ final class LegacyContent
$row->legacy_identity = $identity;
$resolved->put($identity, $row);
}
return $resolved;
}
+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'];
/** 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
{
$errors = [];
@@ -44,7 +47,10 @@ class OpportunityCanonicalContract
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
{
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
@@ -173,6 +179,7 @@ class OpportunityCanonicalContract
$args = $node->args();
if ($node->keyword() === 'type' && in_array($prefix, ['canonical.initiative.responsible_name', 'canonical.initiative.coverage_statement'], true)) {
$errors[$prefix][] = 'Informação em falta: preencha este campo antes de submeter.';
return;
}
@@ -298,7 +305,7 @@ class OpportunityCanonicalContract
&& hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.');
if ($file) {
// Verify actual bytes/cipher integrity as well as the recorded scan hash.
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256);
}
+58 -11
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@@ -40,6 +41,10 @@ class OpportunityClassification
public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired'];
/**
* @param array<array-key, mixed> $schema
* @return array<array-key, mixed>
*/
public function extend(array $schema): array
{
$fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)],
@@ -86,7 +91,8 @@ class OpportunityClassification
$query->whereIn('cluster_id', $this->targets('cluster')->select('id'));
}
if ($kind === 'location') {
$query->whereIn('id', app(InvestmentGeography::class)->publicReferences()->select('id'));
// Use the authoritative public scope directly; wrapping it in a second scan multiplies recursive ancestry checks.
$query = app(InvestmentGeography::class)->publicReferences();
}
if (in_array($kind, ['zone', 'park'], true)) {
return $query->whereIn('id', app(InvestmentZones::class)->visible()->select('id'))->when($kind === 'park', fn ($q) => $q->where('category', 'industrial_park'));
@@ -100,6 +106,9 @@ class OpportunityClassification
return app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind);
}
/**
* @return array<array-key, mixed>
*/
public function options(): array
{
$targets = [];
@@ -112,6 +121,9 @@ class OpportunityClassification
return ['version' => self::VERSION, 'dimensions' => self::DIMENSIONS, 'types' => array_map(fn ($v) => $v[2], self::TARGETS), 'purposes' => self::PURPOSES, 'states' => self::STATES, 'externalStates' => self::EXTERNAL, 'targets' => $targets];
}
/**
* @param array<array-key, mixed> $payload
*/
public function validate(array $payload, Opportunity $record): void
{
$previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : [];
@@ -185,16 +197,26 @@ class OpportunityClassification
}
}
/** Exact persisted relation retention is not permission to choose a withdrawn target anew. */
/** Exact persisted relation retention is not permission to choose a withdrawn target anew.
* @param array<array-key, mixed> $previousRows
* @param array<array-key, mixed> $row
*/
public function canRetainReference(string $kind, string $id, array $row, array $previousRows): bool
{
if ($this->targets($kind)->where('id', $id)->exists()) { return true; }
if (! app(EconomicReferenceCatalog::class)->historicalTarget($kind, $id)) { return false; }
if ($this->targets($kind)->where('id', $id)->exists()) {
return true;
}
if (! app(EconomicReferenceCatalog::class)->historicalTarget($kind, $id)) {
return false;
}
$content = fn ($r) => array_intersect_key($r, array_flip(['related_id', 'relation_type', 'classification']));
return collect($previousRows)->contains(fn ($old) => OpportunityCanonicalValue::hash($content($old)) === OpportunityCanonicalValue::hash($content($row)));
}
/**
* @param array<array-key, mixed> $row
*/
public function hash(array $row): string
{
// Client identity/status cannot certify a relation. Object-order normalization is reused.
@@ -212,6 +234,9 @@ class OpportunityClassification
&& ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true));
}
/**
* @param array<array-key, mixed> $row
*/
public function state(Opportunity $record, array $row): string
{
$decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null;
@@ -219,7 +244,7 @@ class OpportunityClassification
return $decision['state'];
}
if ($decision) {
$reviewer = User::find($decision['actor_id']);
$reviewer = User::whereKey($decision['actor_id'])->first();
$c = $row['classification'];
if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) {
return 'in_validation';
@@ -239,6 +264,9 @@ class OpportunityClassification
return $row['classification']['requested_state'] ?? 'preliminary';
}
/**
* @param array<array-key, mixed> $row
*/
private function evidenceHash(array $row): ?string
{
$id = $row['classification']['document_id'] ?? null;
@@ -251,6 +279,9 @@ class OpportunityClassification
return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]);
}
/**
* @return array<array-key, mixed>
*/
public function listing(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
@@ -271,11 +302,14 @@ class OpportunityClassification
}
return ['hash' => $this->hash($row), 'dimension' => $dimension, 'relation' => $row, 'state' => $this->state($record, $row),
'metadata' => collect($metadata['active'] ?? [])->first(fn ($m) => ($m['fingerprint'] ?? '') === $this->hash($row)), 'previous_versions' => $prior,
'metadata' => collect(StructuredData::collectionItems($metadata['active'] ?? []))->first(fn ($m) => ($m['fingerprint'] ?? '') === $this->hash($row)), 'previous_versions' => $prior,
'can_review' => $this->canReview($actor, $record, $dimension), 'history' => $record->workflow_context['classification_decisions'][$this->hash($row)]['history'] ?? []];
}, $rows);
}
/**
* @param array<array-key, mixed> $input
*/
public function review(User $actor, Opportunity $record, array $input): Opportunity
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'hash' => 'required|string|size:64', 'state' => 'required|in:in_validation,confirmed,rejected,archived,revoked', 'reason' => 'required|string|min:20|max:1000'])->validate();
@@ -291,7 +325,7 @@ class OpportunityClassification
if (in_array($data['state'], ['revoked', 'archived'], true)) {
$rows = [...$rows, ...($record->workflow_context['publication']['classification'] ?? [])];
}
$row = collect($rows)->first(fn ($r) => isset($r['classification']) && $this->hash($r) === $data['hash']);
$row = collect(StructuredData::collectionItems($rows))->first(fn ($r) => isset($r['classification']) && $this->hash($r) === $data['hash']);
abort_unless($row, 409, 'Associação inexistente nesta versão.');
$c = $row['classification'];
abort_unless($this->canReview($actor, $record, self::TARGETS[$c['target_type']][2]), 403, 'Revisão exige âmbito, responsabilidade, MFA e independência aprovados.');
@@ -314,12 +348,18 @@ class OpportunityClassification
});
}
/** Frozen at the existing editorial publication, not on draft save or association confirmation. */
/** Frozen at the existing editorial publication, not on draft save or association confirmation.
* @param array<array-key, mixed> $payload
* @return array<array-key, mixed>
*/
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<array-key, mixed>
*/
public function publicRows(Opportunity $record): array
{
if ($record->trashed() || $record->status !== 'published' || ! $record->published_at || $record->published_at->isFuture() || ($record->workflow_context['publication']['visibility'] ?? '') !== 'public') {
@@ -343,7 +383,7 @@ class OpportunityClassification
// Original documents, internal rationale, actor IDs and decision history stay private.
$rows[] = ['dimension' => self::TARGETS[$kind][2], 'target_type' => $kind, 'target_id' => (string) $row['related_id'], 'label' => $target->{self::TARGETS[$kind][1]},
'purpose' => $c['purpose'], 'purpose_label' => self::PURPOSES[$c['purpose']], 'target_code' => $target->code ?? null, 'catalog_version' => $target->catalog_version_id ?? null,
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)?->path ?? []) : [],
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)->path ?? []) : [],
'physical_category' => $target->physical_category ?? null, 'legal_regime' => $target->legal_regime ?? null,
'state' => $state, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null,
'authority_id' => $c['authority_id'] ?? null, 'external_status' => $c['external_status'] ?? 'not_assessed', 'availability_confirmed' => false,
@@ -353,7 +393,10 @@ class OpportunityClassification
return $rows;
}
/** Same public projection for count/list/map; AND across filters, OR across selected associations. */
/** Same public projection for count/list/map; AND across filters, OR across selected associations.
* @param array<array-key, mixed> $filters
* @param Builder<Opportunity> $query
*/
public function constrain(Builder $query, array $filters): void
{
$keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id'];
@@ -383,7 +426,7 @@ class OpportunityClassification
$matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']);
}
if (! empty($filters['subsector_id'])) {
$matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id','economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id']));
$matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id', 'economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id']));
}
if ($matches) {
$ids[] = $record->id;
@@ -393,9 +436,13 @@ class OpportunityClassification
$query->whereIn('opportunities.id', $ids);
}
/**
* @return array<array-key, mixed>
*/
public function duplicates(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
// Exact-title candidates only within the actor's existing visibility; never merge or reveal another dossier.
return app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->whereKeyNot($record->id)
->whereRaw('lower(trim(title))=lower(trim(?))', [$record->title])->limit(10)->get(['id', 'reference', 'title'])
+6 -2
View File
@@ -14,6 +14,10 @@ use Illuminate\Validation\ValidationException;
/** Reuse the CRM master without manufacturing an interest, lead or consent. */
class OpportunityContacts
{
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function save(User $actor, int $id, array $input): array
{
$access = app(PortalProfileAccess::class);
@@ -63,7 +67,7 @@ class OpportunityContacts
$investors = Investor::where('organization_id', $record->organization_id)->whereIn('status', ['prospect', 'active'])->lockForUpdate()->get();
if (! empty($data['investor_id'])) {
$investor = $investors->firstWhere('id', $data['investor_id']);
abort_unless($investor, 403, 'O cadastro selecionado não pertence ao promotor desta oportunidade.');
abort_unless($investor !== null, 403, 'O cadastro selecionado não pertence ao promotor desta oportunidade.');
} elseif ($investors->count() === 1) {
$investor = $investors->first();
} elseif ($investors->count() > 1) {
@@ -73,7 +77,7 @@ class OpportunityContacts
throw ValidationException::withMessages(['investor_type' => 'Indique a classificação do promotor no cadastro existente.']);
}
$organization = DB::table('organizations')->where('id', $record->organization_id)->whereNull('deleted_at')->first();
abort_unless($organization, 422, 'Organização indisponível.');
abort_unless($organization !== null, 422, 'Organização indisponível.');
$investor = new Investor;
PortalAudit::save($investor, ['organization_id' => $organization->id, 'legal_name' => $organization->name,
'investor_type' => $data['investor_type'], 'status' => 'prospect', 'source_system' => 'portal-promoter-master',
+16 -7
View File
@@ -2,11 +2,15 @@
namespace App\Services;
use App\Models\Opportunity;
use App\Support\StructuredData;
/** Read-only grouping of the existing public catalogue. */
final class OpportunityCorridorExplorer
{
/**
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
*/
public function groups(array $filters): array
{
$catalogue = app(InvestmentCatalogue::class);
@@ -14,7 +18,7 @@ final class OpportunityCorridorExplorer
foreach ($catalogue->query($filters)->get() as $record) {
$context = $catalogue->context($record->id);
$classification = $context['classification'] ?? [];
$corridors = collect($context['corridors'])->mapWithKeys(fn ($c) => ['corridor:'.$c->id => $c->name])->all();
$corridors = collect(StructuredData::collectionItems($context['corridors']))->mapWithKeys(fn ($c) => ['corridor:'.$c->id => $c->name])->all();
foreach ($classification as $relation) {
if ($relation['target_type'] === 'corridor' && ! in_array($relation['purpose'], ['proximity', 'administrative_boundary'], true)) {
$corridors['corridor:'.$relation['target_id']] = $relation['label'];
@@ -27,12 +31,16 @@ final class OpportunityCorridorExplorer
$corridors['source:'.hash('sha256', $source['corridor'])] = $source['corridor'];
$chain = $source['chain'];
}
$chains = collect($classification)->where('target_type', 'chain')->pluck('label')->all();
if ($chain) $chains[] = $chain;
$stages = collect($classification)->pluck('production_stage')->filter()->unique()->values()->all();
$chains = collect(StructuredData::collectionItems($classification))->where('target_type', 'chain')->pluck('label')->all();
if ($chain) {
$chains[] = $chain;
}
$stages = collect(StructuredData::collectionItems($classification))->pluck('production_stage')->filter()->unique()->values()->all();
// Keep the source's actual activity description rather than inventing stage labels.
if ($stages === [] && $chain && ! empty($source['summary'])) $stages = [$source['summary']];
$path = collect($context['primary_territory_path'] ?? []);
if ($stages === [] && $chain && ! empty($source['summary'])) {
$stages = [$source['summary']];
}
$path = collect(StructuredData::collectionItems($context['primary_territory_path'] ?? []));
$province = $path->firstWhere('location_type', 'province')->name ?? $record->province;
$district = $path->firstWhere('location_type', 'district')->name ?? $record->district;
$contextProvinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record);
@@ -53,6 +61,7 @@ final class OpportunityCorridorExplorer
}
unset($group);
uasort($groups, fn ($a, $b) => ($a['id'] === 'other' ? 1 : 0) <=> ($b['id'] === 'other' ? 1 : 0) ?: strnatcasecmp($a['name'], $b['name']));
return array_values($groups);
}
}
+2 -1
View File
@@ -45,7 +45,7 @@ class OpportunityImageProcessor
$previousDerivatives = $previousPayload['derivatives'] ?? [];
$expectedWidths = array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280]));
if (($previousPayload['derived_from_sha256'] ?? null) === trim($doc->sha256)
&& array_values(array_column($previousDerivatives, 'width')) === array_values($expectedWidths)) {
&& array_column($previousDerivatives, 'width') === array_values($expectedWidths)) {
$reusable = true;
$retry = [];
foreach ($previousDerivatives as $derivative) {
@@ -65,6 +65,7 @@ class OpportunityImageProcessor
}
if ($reusable) {
$documents = $retry;
return; // Retry scans of the same streams, without new files, versions or audit decisions.
}
}
@@ -11,17 +11,28 @@ class OpportunityInitiativeApplicability
'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
{
return ($p['opportunity_category'] ?? null) === 'investment_project'
&& in_array($p['initiative']['kind'] ?? null, ['program', 'portfolio'], true);
}
/**
* @param array<array-key, mixed> $p
*/
public function applicable(array $p, string $group): bool
{
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
{
$base = &$schema['$defs']['opportunityCreate'];
@@ -42,6 +53,7 @@ class OpportunityInitiativeApplicability
if (isset($payload['initiative'])) {
$base['allOf'][] = ['properties' => ['opportunity_category' => ['const' => 'investment_project']]];
}
return $schema;
}
$ignored = [];
@@ -73,9 +85,14 @@ class OpportunityInitiativeApplicability
if (! $this->applicable($payload, 'physical_site')) {
unset($base['properties']['locations']['minItems']);
}
return $schema;
}
/**
* @param array<array-key, mixed> $errors
* @param array<array-key, mixed> $p
*/
public function validate(array $p, array &$errors): void
{
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\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@@ -11,12 +12,15 @@ use Illuminate\Support\Facades\Validator;
/** An existing opportunity resource references the original document. No binary or permission is copied. */
class OpportunityLibrarySupport
{
/**
* @return array<array-key, mixed>
*/
public function choices(User $actor): array
{
return $this->sources($actor)->orderBy('r.id')->limit(100)->get(['r.id', 'r.title', 'r.version_label', 'r.metadata_version', 'r.published', 'd.sha256'])->all();
}
private function sources(User $actor)
private function sources(User $actor): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted')
@@ -24,18 +28,21 @@ class OpportunityLibrarySupport
->whereIn('d.data_classification', ['internal', 'public'])
->when(! $actor->can('manage-portal'), function ($query) use ($actor) {
$query->where(function ($scope) use ($actor) {
$scope->where(fn ($public) => $public->where('r.published',true)->where('d.data_classification','public')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256'));
$scope->where(fn ($public) => $public->where('r.published', true)->where('d.data_classification', 'public')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256'));
if ($actor->hasVerifiedEmail()) {
$scope->orWhere(fn ($own) => $own->where('r.created_by',$actor->id)->where('d.uploaded_by',$actor->id));
$scope->orWhere(fn ($own) => $own->where('r.created_by', $actor->id)->where('d.uploaded_by', $actor->id));
}
$editorial=app(DocumentManagementAccess::class);
$editorial = app(DocumentManagementAccess::class);
if ($editorial->editorial($actor)) {
$scope->orWhereIn('r.id',$editorial->editorialResources($actor)->select('r.id'));
$scope->orWhereIn('r.id', $editorial->editorialResources($actor)->select('r.id'));
}
});
});
}
/**
* @param array<array-key, mixed> $input
*/
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass
{
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
@@ -45,12 +52,13 @@ class OpportunityLibrarySupport
$opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail();
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
$source = $this->sources($actor)->where('r.id', $data['resource_id'])->lockForUpdate()->first(['r.*', 'd.id as original_id', 'd.sha256', 'd.version_no']);
abort_unless($source, 403, 'O acesso actual à biblioteca não permite reutilizar este original.');
abort_unless($source !== null, 403, 'O acesso actual à biblioteca não permite reutilizar este original.');
$existing = DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->where('document_id', $source->original_id)->first();
if ($existing) {
$payload = json_decode($existing->payload, true);
abort_unless(($payload['library_source']['resource_id'] ?? null) === $source->id, 409, 'Original já associado por outro percurso; recuperar o suporte existente.');
abort_unless(($payload['section'] ?? null) === ($data['section'] ?? null) && ($payload['supported_statement'] ?? null) === ($data['supported_statement'] ?? null), 409, 'Original já associado com outra referência; editar explicitamente o suporte existente.');
return $existing;
}
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($opportunity), $data['etag']), 409, 'A ficha mudou; preserve a selecção e recupere a versão actual.');
@@ -71,6 +79,7 @@ class OpportunityLibrarySupport
'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]);
$opportunity->management_version++;
$opportunity->save();
return $after;
});
}
@@ -79,19 +88,28 @@ class OpportunityLibrarySupport
{
$payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload;
$pin = $payload['library_source'] ?? null;
if (! $pin) return;
if (! $pin) {
return;
}
$source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first();
abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted'
&& in_array($document->data_classification, ['internal', 'public'], true)
&& $document->id === $pin['document_id'] && hash_equals($pin['sha256'], trim($document->sha256)), 403, 'O original está retirado, restrito ou já não corresponde à versão guardada.');
}
/**
* @return array<array-key, mixed>
*/
public function snapshot(User $actor, Opportunity $opportunity): array
{
$access = app(PortalProfileAccess::class);
if (! $access->canView($actor, $opportunity)) return [];
if (! $access->canView($actor, $opportunity)) {
return [];
}
return collect(app(OpportunityResources::class)->listing($opportunity->id, 'documents'))->filter(fn ($r) => ! $r->deleted_at && isset($r->payload['library_source']))->map(function ($r) use ($opportunity) {
$pin = $r->payload['library_source'];
return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version,
'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id,
'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']];
+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.');
}
} elseif ($profileCreation) {
abort_unless(($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? '') || (isset($input['proactive']) && empty($input['organization_id']) && !$access->has($actor, 'auditor') && $access->internalAssignment($actor, $input['opportunity_category'] ?? '') !== null)), 403);
abort_unless(($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? '') || (isset($input['proactive']) && empty($input['organization_id']) && ! $access->has($actor, 'auditor') && $access->internalAssignment($actor, $input['opportunity_category'] ?? '') !== null)), 403);
} else {
abort_unless($access->legacyWriteAllowed($actor), 403, 'O papel v1 não usa a criação legada para contornar o seu âmbito.');
}
@@ -128,7 +128,9 @@ class OpportunityManagement
$this->check($record, $data['etag']);
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
}
if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor);
if (array_key_exists('proactive', $data)) {
$data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor);
}
$before = $record->getAttributes();
$previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : [];
$changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS]));
@@ -172,12 +174,12 @@ class OpportunityManagement
$record->management_version = 1;
$record->estimated_investment_currency = null;
if ($profileCreation) {
abort_unless(($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? '') || (isset($data['proactive']) && empty($data['organization_id']) && !$access->has($actor, 'auditor') && $access->internalAssignment($actor, $data['opportunity_category'] ?? '') !== null)), 403);
abort_unless(($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? '') || (isset($data['proactive']) && empty($data['organization_id']) && ! $access->has($actor, 'auditor') && $access->internalAssignment($actor, $data['opportunity_category'] ?? '') !== null)), 403);
$record->owner_user_id = $actor->id;
$record->organization_id = $data['organization_id'] ?? null;
$record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]];
if (($internal=$access->internalAssignment($actor,$data['opportunity_category'] ?? '')) !== null) {
$record->workflow_context=[...$record->workflow_context,'operator_organization_id'=>$internal->organization_id,'internal_assignment_id'=>$internal->id];
if (($internal = $access->internalAssignment($actor, $data['opportunity_category'] ?? '')) !== null) {
$record->workflow_context = [...$record->workflow_context, 'operator_organization_id' => $internal->organization_id, 'internal_assignment_id' => $internal->id];
}
}
} else {
@@ -227,7 +229,9 @@ class OpportunityManagement
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
abort_if($record->owner_user_id !== null, 403, 'A v1 exige pedido e decisão de arquivo; não concede exclusão ou restauro ao Promotor.');
$this->check($record, $data['etag']);
if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor);
if (array_key_exists('proactive', $data)) {
$data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor);
}
$before = $record->getAttributes();
if ($data['action'] === 'restore') {
abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.');
+10 -2
View File
@@ -2,6 +2,8 @@
namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
/** Catalogue versions are not interchangeable: historical positions keep their meaning. */
@@ -24,7 +26,9 @@ final class OpportunityMaturity
public const LEGACY_LABELS = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
/** Public selection is explicit; historical numbers never stand for current stages. */
/** Public selection is explicit; historical numbers never stand for current stages.
* @return array<array-key, mixed>
*/
public static function searchOptions(): array
{
$options = [];
@@ -34,10 +38,14 @@ final class OpportunityMaturity
foreach (self::LEGACY_LABELS as $level => $label) {
$options[] = ['value' => 'historical:'.$level, 'label' => $label.' — registo histórico', 'group' => 'Classificações históricas'];
}
return $options;
}
public static function constrainSearch(\Illuminate\Database\Eloquent\Builder $query, string $selection): void
/**
* @param Builder<Opportunity> $query
*/
public static function constrainSearch(Builder $query, string $selection): void
{
[$kind, $value] = explode(':', $selection, 2);
if ($kind === 'stage') {
+59 -15
View File
@@ -3,7 +3,9 @@
namespace App\Services;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@@ -14,16 +16,22 @@ use Illuminate\Validation\Rule;
final class OpportunityPromotion
{
public const PURPOSE = 'promotional_preparation';
public const NOTICE = 'Em preparação. A divulgação não confirma financiamento, disponibilidade de terreno, licenças ou autorização de execução do investimento.';
public function allowed(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
if ($record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
|| $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) return false;
|| $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) {
return false;
}
// Never overwrite a normal approved publication or its workflow decisions.
if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) return false;
if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) {
return false;
}
$internal = $access->internalAssignment($actor, $record->opportunity_category);
return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id))
|| ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'publish', $record->id));
@@ -33,10 +41,13 @@ final class OpportunityPromotion
{
abort_unless($this->allowed($actor, $record), 403, 'A designação e o âmbito existentes não autorizam esta divulgação.');
if (! app(PortalProfileAccess::class)->mfa($actor)) {
throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required'));
throw new HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required'));
}
}
/**
* @return array<array-key, mixed>
*/
public function editor(User $actor, Opportunity $record): array
{
abort_unless($this->allowed($actor, $record), 403);
@@ -47,15 +58,20 @@ final class OpportunityPromotion
'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '',
'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [],
'sector_id' => $record->sector_id];
return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record),
'proactiveRelations' => app(ProactiveInvestment::class)->publicationRelations($record), 'proactiveKind' => app(OpportunityManagement::class)->values($record)['proactive']['kind'] ?? null, 'proactiveEnabled' => isset(app(OpportunityManagement::class)->values($record)['proactive']), 'proactiveEvidence' => array_values(array_filter(app(OpportunityManagement::class)->values($record)['proactive']['evidence'] ?? [], fn ($e) => $e['access'] === 'public')), 'proactiveOptions' => app(ProactiveInvestment::class)->options(), 'initial' => [...$form, ...($saved['input']['proactive_public'] ?? null ? ['proactive_public' => $saved['input']['proactive_public']] : [])], 'publicNow' => $existing && $record->status === 'published',
'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor),
'sectors' => \App\Models\Sector::catalog()->orderBy('name')->get(['id', 'name']),
'sectors' => Sector::catalog()->orderBy('name')->get(['id', 'name']),
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']),
'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')
->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function preview(User $actor, Opportunity $record, array $input): array
{
$this->authorize($actor, $record);
@@ -64,11 +80,13 @@ final class OpportunityPromotion
'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500',
'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d',
'proactive_public' => 'sometimes|array', 'proactive_reviewed' => 'sometimes|boolean',
'sector_id' => ['nullable', 'integer', Rule::in(\App\Models\Sector::catalog()->pluck('id')->all())],
'sector_id' => ['nullable', 'integer', Rule::in(Sector::catalog()->pluck('id')->all())],
'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
])->validate();
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.');
if ($data['action'] === 'withdraw') abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409);
if ($data['action'] === 'withdraw') {
abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409);
}
$paths = [];
foreach ($data['territory_ids'] ?? [] as $id) {
$path = app(InvestmentGeography::class)->path($id, true);
@@ -84,22 +102,38 @@ final class OpportunityPromotion
$projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body,
'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE,
'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)];
if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) { $projection['proactive'] = $proactive; $projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]); }
if ($data['action'] === 'disclose') abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.');
if ($data['action'] === 'withdraw') $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])];
if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) {
$projection['proactive'] = $proactive;
$projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]);
}
if ($data['action'] === 'disclose') {
abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.');
}
if ($data['action'] === 'withdraw') {
$projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])];
}
$payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'],
'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp];
return ['action' => $data['action'], 'projection' => $projection, 'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function confirm(User $actor, int $id, array $input): array
{
$data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid',
'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate();
try { $preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR); }
catch (\Throwable) { abort(422, 'Prévia inválida. Prepare outra prévia.'); }
try {
$preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable) {
abort(422, 'Prévia inválida. Prepare outra prévia.');
}
abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403);
return DB::transaction(function () use ($actor, $id, $data, $preview) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]);
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
@@ -110,6 +144,7 @@ final class OpportunityPromotion
if ($replay) {
$prior = json_decode($replay->after_data, true);
abort_unless($prior['record_id'] === $id && (int) $replay->actor_user_id === $actor->id && hash_equals($prior['request_hash'], $digest), 409, 'Chave usada noutra operação.');
return ['id' => $id, 'replayed' => true, 'message' => 'Operação já registada; nenhuma alteração repetida.'];
}
abort_unless(($preview['expires'] ?? 0) >= now()->timestamp, 409, 'A prévia expirou. Prepare outra prévia.');
@@ -124,7 +159,9 @@ final class OpportunityPromotion
$action = 'promotional_summary_withdrawn';
} else {
$p = $preview['projection'];
if ($p['sector_id'] !== null) abort_unless(\App\Models\Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.');
if ($p['sector_id'] !== null) {
abort_unless(Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.');
}
$paths = [];
foreach ($preview['input']['territory_ids'] ?? [] as $territory) {
$path = app(InvestmentGeography::class)->path($territory, true);
@@ -141,15 +178,21 @@ final class OpportunityPromotion
$context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id,
'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true];
$action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published';
foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) $record->{$field} = $p[$field];
foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) $record->{$field} = null;
foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) {
$record->{$field} = $p[$field];
}
foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) {
$record->{$field} = null;
}
$main = $paths[0] ?? [];
$record->primary_territory_id = $main ? end($main)->id : null;
$record->province = collect($main)->firstWhere('location_type', 'province')?->name;
$record->district = collect($main)->firstWhere('location_type', 'district')?->name;
$record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified');
DB::table('opportunity_locations')->where('opportunity_id', $id)->delete();
foreach ($preview['input']['territory_ids'] ?? [] as $territory) DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]);
foreach ($preview['input']['territory_ids'] ?? [] as $territory) {
DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]);
}
$record->status = 'published';
$record->is_featured = false;
$record->last_validated_at = null;
@@ -164,6 +207,7 @@ final class OpportunityPromotion
'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest,
'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false,
'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]);
return ['id' => $id, 'replayed' => false, 'message' => $preview['action'] === 'withdraw' ? 'Resumo retirado; cadastro e histórico preservados.' : 'Resumo divulgado como Em preparação. Não autoriza execução do investimento.'];
});
}
+13 -4
View File
@@ -10,7 +10,9 @@ final class OpportunityPublicText
public static function description(Opportunity $record, string $text): string
{
$source = data_get($record->workflow_context, 'corridor_agribusiness');
if (! is_array($source) || ($source['source_sha256'] ?? null) !== CorridorAgribusinessOpportunities::SOURCE_HASH) return $text;
if (! is_array($source) || ($source['source_sha256'] ?? null) !== CorridorAgribusinessOpportunities::SOURCE_HASH) {
return $text;
}
$hash = CorridorAgribusinessOpportunities::SOURCE_HASH;
$sourceLine = 'Fonte: '.$source['source'].'; SHA-256 '.$hash;
@@ -20,13 +22,20 @@ final class OpportunityPublicText
$paragraph = trim($paragraph);
if (in_array($paragraph, [$sourceLine, $sourceLine.'.', 'Edição: Não indicada.', 'Data-base: Não indicada.',
'Limitações e lacunas: '.CorridorAgribusinessOpportunities::LIMITATIONS,
OpportunityPromotion::NOTICE], true)) continue;
if ($paragraph === 'Enquadramento documental: '.$source['corridor'].'.') $paragraph = $source['corridor'].'.';
OpportunityPromotion::NOTICE], true)) {
continue;
}
if ($paragraph === 'Enquadramento documental: '.$source['corridor'].'.') {
$paragraph = $source['corridor'].'.';
}
if ($paragraph === CorridorAgribusinessOpportunities::LIMITATIONS) {
$paragraph = 'A localização exacta, a disponibilidade de terreno e as condições do investimento serão definidas durante a preparação do projecto.';
}
if ($paragraph !== '') $result[] = $paragraph;
if ($paragraph !== '') {
$result[] = $paragraph;
}
}
return implode("\n\n", $result);
}
}
@@ -7,22 +7,33 @@ use App\Models\Opportunity;
/** Read-only presentation of existing references. Does not reconcile, confirm or save data. */
class OpportunityTerritorialPresentation
{
/**
* @param array<array-key, mixed> $locations
* @return array<array-key, mixed>
*/
public function describe(Opportunity $record, array $locations): array
{
$path = array_values(array_filter(app(InvestmentGeography::class)->path($record->primary_territory_id, true), fn ($node) => in_array($node->location_type, ['province', 'district', 'admin_post', 'locality'], true)));
$mainIds = array_column($path, 'id', 'location_type');
$differences = [];
foreach ($locations as $location) {
if ($location->deleted_at || empty($location->payload['is_primary']) || !$path) continue;
if ($location->deleted_at || empty($location->payload['is_primary']) || ! $path) {
continue;
}
$p = $location->payload;
$different = false;
foreach ($mainIds as $level => $id) if (!empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) $different = true;
$foreign = !empty($p['country_code']) && $p['country_code'] !== 'MZ';
foreach ($mainIds as $level => $id) {
if (! empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) {
$different = true;
}
}
$foreign = ! empty($p['country_code']) && $p['country_code'] !== 'MZ';
if ($foreign || $different) {
$differences[] = ['resource_id' => $location->id, 'title' => $p['title'] ?? 'Localização sem título',
$differences[] = ['resource_id' => data_get($location, 'id'), 'title' => $p['title'] ?? 'Localização sem título',
'message' => 'A referência principal da ficha e esta localização documentada apontam para territórios diferentes. Confirme se a distinção é intencional; nenhum valor foi substituído.'];
}
}
return ['path' => $path, 'differences' => $differences];
}
}
+104 -68
View File
@@ -11,95 +11,131 @@ use Illuminate\Validation\Rule;
/** Owner-directed staff assignments use existing identities, organizations and grants. */
class PortalInternalDesignation
{
/**
* @return array<array-key, mixed>
*/
public function organizations(): array
{
return DB::table('organizations')->whereIn(DB::raw('id::text'), DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered')->selectRaw("after_data->>'organization_id'"))->whereNull('deleted_at')
->get(['id','name','external_id','version_no'])->map(fn($r)=>$this->organization($r->id))->filter()->values()->all();
return DB::table('organizations')->whereIn(DB::raw('id::text'), DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered')->selectRaw("after_data->>'organization_id'"))->whereNull('deleted_at')
->get(['id', 'name', 'external_id', 'version_no'])->map(fn ($r) => $this->organization($r->id))->filter()->values()->all();
}
/**
* @return array<array-key, mixed>|null
*/
public function organization(?string $id): ?array
{
if ($id===null) return null;
$r=DB::table('organizations')->where('id',$id)->whereNull('deleted_at')->first();
if (!$r) return null;
$event=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','operator_organization_registered')
->where('after_data->organization_id',$id)->orderByDesc('occurred_at')->first();
if (!$event) return null;
$data=json_decode($event->after_data,true,512,JSON_THROW_ON_ERROR);
if (($data['organization']['name'] ?? null)!==$r->name || ($data['organization']['nuit'] ?? null)!==$r->external_id) return null;
return ['id'=>$r->id,'version'=>$r->version_no,...$data['organization'],'source_reference'=>$data['source_reference']];
if ($id === null) {
return null;
}
$r = DB::table('organizations')->where('id', $id)->whereNull('deleted_at')->first();
if (! $r) {
return null;
}
$event = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered')
->where('after_data->organization_id', $id)->orderByDesc('occurred_at')->first();
if (! $event) {
return null;
}
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
if (($data['organization']['name'] ?? null) !== $r->name || ($data['organization']['nuit'] ?? null) !== $r->external_id) {
return null;
}
return ['id' => $r->id, 'version' => $r->version_no, ...$data['organization'], 'source_reference' => $data['source_reference']];
}
public function registerOrganization(User $actor,array $input): array
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function registerOrganization(User $actor, array $input): array
{
app(PortalProfileAdministration::class)->administrative($actor);
$v=Validator::make($input,['operation_key'=>'required|uuid','name'=>'required|string|max:200','nuit'=>'required|string|regex:/^[0-9]{9}$/',
'contact'=>'required|string|regex:/^\+258[0-9]{9}$/','director'=>'required|string|max:200',
'function'=>'required|in:portal_owner_operator','source_reference'=>'required|string|min:20|max:1000'])->validate();
return DB::transaction(function()use($actor,$v){
$v = Validator::make($input, ['operation_key' => 'required|uuid', 'name' => 'required|string|max:200', 'nuit' => 'required|string|regex:/^[0-9]{9}$/',
'contact' => 'required|string|regex:/^\+258[0-9]{9}$/', 'director' => 'required|string|max:200',
'function' => 'required|in:portal_owner_operator', 'source_reference' => 'required|string|min:20|max:1000'])->validate();
return DB::transaction(function () use ($actor, $v) {
DB::select('SELECT pg_advisory_xact_lock(101026,40001)');
$payload=array_intersect_key($v,array_flip(['name','nuit','contact','director','function']));
$existingEvent=DB::table('audit_events')->where('source_system','portal-profiles-v1')->whereIn('action',['operator_organization_registered','operator_organization_registration_recovered'])->where('after_data->operation_key',$v['operation_key'])->first();
if($existingEvent){
$e=json_decode($existingEvent->after_data,true);
abort_unless($e['organization']==$payload && $e['source_reference']===$v['source_reference'],409,'Esta chave corresponde a outro cadastro.');
return ['id'=>$e['organization_id'],'organization'=>$this->organization($e['organization_id']),'message'=>'Cadastro recuperado; não repetido.'];
$payload = array_intersect_key($v, array_flip(['name', 'nuit', 'contact', 'director', 'function']));
$existingEvent = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['operator_organization_registered', 'operator_organization_registration_recovered'])->where('after_data->operation_key', $v['operation_key'])->first();
if ($existingEvent) {
$e = json_decode($existingEvent->after_data, true);
abort_unless($e['organization'] == $payload && $e['source_reference'] === $v['source_reference'], 409, 'Esta chave corresponde a outro cadastro.');
return ['id' => $e['organization_id'], 'organization' => $this->organization($e['organization_id']), 'message' => 'Cadastro recuperado; não repetido.'];
}
$names=array_unique([$v['name'],preg_replace('/\s*,\s*/',' ',$v['name'])]);
$matches=DB::table('organizations')->where(fn($q)=>$q->where('external_id',$v['nuit'])->orWhereIn('name',$names)->orWhere('name',str_replace(' Lda',', Lda',$v['name'])))->lockForUpdate()->get();
abort_if($matches->count()>1,409,'Correspondência ambígua no cadastro mestre; não criar duplicado.');
$r=$matches->first();
if($r){
abort_if($r->deleted_at!==null || ($r->external_id!==null && $r->external_id!==$v['nuit']),409,'Identidade ou estado da organização exige reconciliação.');
$known=$this->organization($r->id);
if($known){
abort_unless(array_intersect_key($known,$payload)==$payload,409,'Dados do cadastro já existente diferem; preservar a versão anterior.');
app(PortalProfileAccess::class)->audit($actor,'operator_organization_registration_recovered',['operation_key'=>$v['operation_key'],'organization_id'=>$r->id,'organization'=>$payload,'source_reference'=>$v['source_reference']]);
return ['id'=>$r->id,'organization'=>$known,'message'=>'Organização existente recuperada.'];
$names = array_unique([$v['name'], preg_replace('/\s*,\s*/', ' ', $v['name'])]);
$matches = DB::table('organizations')->where(fn ($q) => $q->where('external_id', $v['nuit'])->orWhereIn('name', $names)->orWhere('name', str_replace(' Lda', ', Lda', $v['name'])))->lockForUpdate()->get();
abort_if($matches->count() > 1, 409, 'Correspondência ambígua no cadastro mestre; não criar duplicado.');
$r = $matches->first();
if ($r) {
abort_if($r->deleted_at !== null || ($r->external_id !== null && $r->external_id !== $v['nuit']), 409, 'Identidade ou estado da organização exige reconciliação.');
$known = $this->organization($r->id);
if ($known) {
abort_unless(array_intersect_key($known, $payload) == $payload, 409, 'Dados do cadastro já existente diferem; preservar a versão anterior.');
app(PortalProfileAccess::class)->audit($actor, 'operator_organization_registration_recovered', ['operation_key' => $v['operation_key'], 'organization_id' => $r->id, 'organization' => $payload, 'source_reference' => $v['source_reference']]);
return ['id' => $r->id, 'organization' => $known, 'message' => 'Organização existente recuperada.'];
}
$id=$r->id;
$before=['name'=>$r->name,'external_id'=>$r->external_id,'source_system'=>$r->source_system,'version_no'=>$r->version_no];
abort_unless(in_array($r->name,$names,true) || $r->name===str_replace(' Lda',', Lda',$v['name']),409,'Denominação divergente exige reconciliação expressa.');
DB::table('organizations')->where('id',$id)->update(['name'=>$v['name'],'external_id'=>$v['nuit'],'updated_at'=>now(),'version_no'=>$r->version_no+1]);
$id = $r->id;
$before = ['name' => $r->name, 'external_id' => $r->external_id, 'source_system' => $r->source_system, 'version_no' => $r->version_no];
abort_unless(in_array($r->name, $names, true) || $r->name === str_replace(' Lda', ', Lda', $v['name']), 409, 'Denominação divergente exige reconciliação expressa.');
DB::table('organizations')->where('id', $id)->update(['name' => $v['name'], 'external_id' => $v['nuit'], 'updated_at' => now(), 'version_no' => $r->version_no + 1]);
// Reconcile only the explicit comma alias; preserve previous values in audit.
}else{
$id=(string)Str::uuid();
DB::table('organizations')->insert(['id'=>$id,'name'=>$v['name'],'external_id'=>$v['nuit'],'source_system'=>'portal-operator-master','organization_type'=>'private']);
} else {
$id = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $id, 'name' => $v['name'], 'external_id' => $v['nuit'], 'source_system' => 'portal-operator-master', 'organization_type' => 'private']);
}
app(PortalProfileAccess::class)->audit($actor,'operator_organization_registered',['operation_key'=>$v['operation_key'],'organization_id'=>$id,
'organization'=>$payload,'previous_master_values'=>$before??null,'source_reference'=>$v['source_reference'],'metadata_storage'=>'existing_audit_snapshot','no_corporate_membership_created'=>true]);
return ['id'=>$id,'organization'=>$this->organization($id),'message'=>'Organização operadora registada no cadastro mestre. Nenhuma representação ou papel foi atribuído.'];
app(PortalProfileAccess::class)->audit($actor, 'operator_organization_registered', ['operation_key' => $v['operation_key'], 'organization_id' => $id,
'organization' => $payload, 'previous_master_values' => $before ?? null, 'source_reference' => $v['source_reference'], 'metadata_storage' => 'existing_audit_snapshot', 'no_corporate_membership_created' => true]);
return ['id' => $id, 'organization' => $this->organization($id), 'message' => 'Organização operadora registada no cadastro mestre. Nenhuma representação ou papel foi atribuído.'];
});
}
public function grant(User $actor,array $input): string
/**
* @param array<array-key, mixed> $input
*/
public function grant(User $actor, array $input): string
{
app(PortalProfileAdministration::class)->administrative($actor);
$v=Validator::make($input,['operation_key'=>'required|uuid','user_id'=>'required|integer|exists:users,id','role'=>['required',Rule::in(['promoter','validator','publisher'])],
'operator_organization_id'=>'required|uuid|exists:organizations,id','organization_id'=>'nullable|uuid|exists:organizations,id',
'opportunity_types'=>'required|array|min:1','opportunity_types.*'=>['required',Rule::in(OpportunityCanonicalContract::BASE_CATEGORIES)],
'confirmed_internal_designation'=>'required|accepted','reason'=>'required|string|min:20|max:1000',
'responsibilities'=>'sometimes|array|max:0','expires_at'=>'nullable|date|after:now'])->validate();
abort_if((int)$v['user_id']===$actor->id,403,'Não pode aprovar a sua atribuição.');
$subject=User::findOrFail($v['user_id']);
abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret!==null && $subject->two_factor_confirmed_at!==null,422,'Confirmar contacto e o próprio MFA antes da atribuição interna.');
$organization=$this->organization($v['operator_organization_id']);
abort_unless($organization!==null,422,'Selecione a organização operadora cadastrada; não exige comprovativos de Promotor externo.');
if($v['role']==='promoter')abort_unless(($v['organization_id']??null)===$v['operator_organization_id'],422,'O vínculo interno é à organização operadora, não uma representação de gestão empresarial.');
return DB::transaction(function()use($actor,$v){
$v = Validator::make($input, ['operation_key' => 'required|uuid', 'user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher'])],
'operator_organization_id' => 'required|uuid|exists:organizations,id', 'organization_id' => 'nullable|uuid|exists:organizations,id',
'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityCanonicalContract::BASE_CATEGORIES)],
'confirmed_internal_designation' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'responsibilities' => 'sometimes|array|max:0', 'expires_at' => 'nullable|date|after:now'])->validate();
abort_if((int) $v['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.');
$subject = User::whereKey($v['user_id'])->firstOrFail();
abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null, 422, 'Confirmar contacto e o próprio MFA antes da atribuição interna.');
$organization = $this->organization($v['operator_organization_id']);
abort_unless($organization !== null, 422, 'Selecione a organização operadora cadastrada; não exige comprovativos de Promotor externo.');
if ($v['role'] === 'promoter') {
abort_unless(($v['organization_id'] ?? null) === $v['operator_organization_id'], 422, 'O vínculo interno é à organização operadora, não uma representação de gestão empresarial.');
}
return DB::transaction(function () use ($actor, $v) {
DB::select('SELECT pg_advisory_xact_lock(101026,40002)');
$subject=User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail();
abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret!==null && $subject->two_factor_confirmed_at!==null && $this->organization($v['operator_organization_id'])!==null,422,'Conta ou organização já não reúne as condições da atribuição.');
$e=DB::table('audit_events')->where('source_system','portal-profiles-v1')->where('action','role_granted')->where('after_data->operation_key',$v['operation_key'])->first();
$payload=array_intersect_key($v,array_flip(['user_id','role','operator_organization_id','organization_id','opportunity_types','reason','expires_at']));
if($e){$d=json_decode($e->after_data,true);abort_unless($d['designation']==$payload,409,'Chave usada numa atribuição diferente.');abort_unless(DB::table('portal_role_assignments')->where('id',$d['assignment_id'])->whereNull('revoked_at')->exists(),409,'A atribuição foi revogada.');return $d['assignment_id'];}
$duplicate=DB::table('portal_role_assignments')->where('user_id',$v['user_id'])->where('role',$v['role'])->where('source','internal-portal-designation')->whereNull('revoked_at')->where('starts_at','<=',now())->where(fn($q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now()))->exists();
abort_if($duplicate,409,'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.');
$id=(string)Str::uuid();
app(PortalProfileAdministration::class)->insertGrant($id,$actor,[...$v,'source'=>'internal-portal-designation']);
app(PortalProfileAccess::class)->audit($actor,'role_granted',['assignment_id'=>$id,'subject_user_id'=>$v['user_id'],'role'=>$v['role'],
'operation_key'=>$v['operation_key'],'designation'=>$payload,'operator_organization_id'=>$v['operator_organization_id'],
'reason'=>$v['reason'],'authority'=>'portal-owner-internal-designation','external_promoter_documents_required'=>false,'no_corporate_membership_created'=>true]);
$subject = User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail();
abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null && $this->organization($v['operator_organization_id']) !== null, 422, 'Conta ou organização já não reúne as condições da atribuição.');
$e = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'role_granted')->where('after_data->operation_key', $v['operation_key'])->first();
$payload = array_intersect_key($v, array_flip(['user_id', 'role', 'operator_organization_id', 'organization_id', 'opportunity_types', 'reason', 'expires_at']));
if ($e) {
$d = json_decode($e->after_data, true);
abort_unless($d['designation'] == $payload, 409, 'Chave usada numa atribuição diferente.');
abort_unless(DB::table('portal_role_assignments')->where('id', $d['assignment_id'])->whereNull('revoked_at')->exists(), 409, 'A atribuição foi revogada.');
return $d['assignment_id'];
}
$duplicate = DB::table('portal_role_assignments')->where('user_id', $v['user_id'])->where('role', $v['role'])->where('source', 'internal-portal-designation')->whereNull('revoked_at')->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists();
abort_if($duplicate, 409, 'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.');
$id = (string) Str::uuid();
app(PortalProfileAdministration::class)->insertGrant($id, $actor, [...$v, 'source' => 'internal-portal-designation']);
app(PortalProfileAccess::class)->audit($actor, 'role_granted', ['assignment_id' => $id, 'subject_user_id' => $v['user_id'], 'role' => $v['role'],
'operation_key' => $v['operation_key'], 'designation' => $payload, 'operator_organization_id' => $v['operator_organization_id'],
'reason' => $v['reason'], 'authority' => 'portal-owner-internal-designation', 'external_promoter_documents_required' => false, 'no_corporate_membership_created' => true]);
return $id;
});
}

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