'Enquadramento territorial', 'production' => 'Organização produtiva', 'regulation' => 'Regime regulatório', 'activity' => 'Atividade económica']; public const TARGETS = [ 'corridor' => ['development_corridors', 'name', 'territory'], 'location' => ['geo_locations', 'name', 'territory'], 'cluster' => ['economic_clusters', 'name', 'production'], 'project' => ['projects', 'name', 'production'], 'chain' => ['value_chains', 'name', 'production'], 'park' => ['investment_zones', 'name', 'production'], 'zone' => ['investment_zones', 'name', 'regulation'], 'sector' => ['sectors', 'name', 'activity'], 'activity' => ['economic_activities', 'label_pt_mz', 'activity'], ]; public const PURPOSES = ['location' => 'Localização', 'coverage' => 'Abrangência documentada', 'proximity' => 'Proximidade, sem pertença', 'administrative_boundary' => 'Limite administrativo', 'parcel' => 'Parcela referenciada', 'connectivity' => 'Conectividade e fluxos', 'productive_complementarity' => 'Complementaridade produtiva', 'specialization' => 'Especialização do cluster', 'anchor' => 'Projeto âncora documentado', 'zone_location' => 'Localização na zona', 'regime_recognition' => 'Reconhecimento do regime', 'enterprise_admission' => 'Admissão do empreendimento', 'specific_benefit' => 'Benefício específico', 'park_operation' => 'Operação do parque', 'financing_need' => 'Necessidade de financiamento', 'payments' => 'Pagamentos', 'risk_management' => 'Gestão de riscos', 'financial_instrument' => 'Instrumento financeiro', 'logistics_service' => 'Serviço logístico', 'physical_asset' => 'Ativo físico/capacidade', 'economic_activity' => 'Atividade económica', 'production_stage' => 'Etapa da cadeia produtiva']; public const STATES = ['draft', 'preliminary', 'in_validation', 'confirmed', 'rejected', 'archived', 'revoked']; public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired']; public function extend(array $schema): array { $fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)], 'purpose' => ['type' => 'string', 'enum' => array_keys(self::PURPOSES)], 'criteria' => ['type' => 'string', 'maxLength' => 2000], 'justification' => ['type' => 'string', 'maxLength' => 4000], 'source_reference' => ['type' => 'string', 'maxLength' => 2000], 'citation' => ['type' => 'string', 'maxLength' => 1000], 'document_id' => ['type' => ['string', 'null'], 'format' => 'uuid'], 'authority_id' => ['type' => ['string', 'null'], 'format' => 'uuid'], 'requested_state' => ['type' => 'string', 'enum' => ['draft', 'preliminary', 'in_validation']], 'disclosure' => ['type' => 'string', 'enum' => ['restricted', 'public']], 'valid_from' => ['type' => ['string', 'null'], 'format' => 'date'], 'valid_until' => ['type' => ['string', 'null'], 'format' => 'date'], 'external_status' => ['type' => 'string', 'enum' => self::EXTERNAL], 'external_act_reference' => ['type' => 'string', 'maxLength' => 2000], 'production_stage' => ['type' => 'string', 'maxLength' => 250], ]; // Same collection for every approved modality; old untyped relations remain valid. $schema['$defs']['relationCreate']['properties']['classification'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => $fields]; $schema['$defs']['relation']['properties']['classification'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => $fields]; foreach (['opportunity', 'opportunityCreate'] as $name) { $schema['$defs'][$name]['properties']['relations'] ??= ['type' => 'array', 'items' => ['$ref' => '#/$defs/relation']]; $schema['$defs'][$name]['properties']['enterprise_size'] = ['type' => ['string', 'null'], 'maxLength' => 120]; } // The extracted Create had generic objects here. Type only the new adapter; // pre-existing relation shapes remain accepted without recertification. $schema['$defs']['opportunityCreate']['properties']['relations']['items'] = ['x-investmoz-classification' => true, 'anyOf' => [ ['type' => 'object', 'not' => ['required' => ['classification']]], ['$ref' => '#/$defs/relationCreate'], ]]; return $schema; } /** Only consultable targets: a promoter's draft permission is not access to private master data. */ public function targets(string $kind): \Illuminate\Database\Query\Builder { abort_unless(isset(self::TARGETS[$kind]), 422); [$table] = self::TARGETS[$kind]; $query = DB::table($table); if (in_array($kind, ['sector', 'activity'], true)) { return $kind === 'sector' ? $query->whereNull('deleted_at')->where('is_active', true) : app(EconomicReferenceCatalog::class)->constrain($query->where('status', 'published'), 'activity'); } $query->whereNull('deleted_at'); if ($kind === 'chain') { $query->whereIn('cluster_id', $this->targets('cluster')->select('id')); } if ($kind === 'location') { $query->whereIn('id', app(InvestmentGeography::class)->publicReferences()->select('id')); } 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')); } if (in_array($kind, ['project', 'cluster', 'corridor'], true)) { $foreign = ['project' => 'project_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id'][$kind]; return $query->where(fn ($q) => $q->where('data_classification', 'public')->orWhereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))); } return app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind); } public function options(): array { $targets = []; foreach (self::TARGETS as $kind => [$table, $label, $dimension]) { $targets[$kind] = $kind === 'activity' ? app(EconomicReferenceCatalog::class)->activities()->map(fn ($r) => (object) ['id' => $r->id, 'label' => $r->display_label, 'code' => $r->code, 'level' => $r->level, 'path' => $r->path, 'source_version' => $r->source_version, 'edition' => $r->edition]) : $this->targets($kind)->when($kind === 'sector', fn ($q) => $q->whereNotNull('published_at'))->orderBy($label)->get(['id', $label.' as label']); } 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]; } public function validate(array $payload, Opportunity $record): void { $previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : []; $catalog = app(EconomicReferenceCatalog::class); $lotIds = array_column($catalog->manifest()['activities'], 'id'); if (in_array($payload['economic_activity_id'] ?? null, $lotIds, true) && ! $catalog->available()) { foreach (['sector_id', 'subsector_id', 'economic_activity_id'] as $field) { if (($payload[$field] ?? null) !== ($previousPayload[$field] ?? null)) { throw ValidationException::withMessages(['canonical.'.$field => 'Referência retirada: apenas a classificação histórica já guardada pode ser conservada.']); } } } $seen = []; foreach ($payload['relations'] ?? [] as $i => $row) { if (! isset($row['classification'])) { continue; } $c = $row['classification']; $prefix = 'canonical.relations.'.$i; $fail = fn ($key, $message) => throw ValidationException::withMessages([$prefix.'.'.$key => $message]); $allowed = ['target_type', 'purpose', 'criteria', 'justification', 'source_reference', 'citation', 'document_id', 'authority_id', 'requested_state', 'disclosure', 'valid_from', 'valid_until', 'external_status', 'external_act_reference', 'production_stage']; if (array_diff(array_keys($c), $allowed)) { $fail('classification', 'Campo de certificação ou metadado não permitido. A decisão é registada pelo revisor autorizado.'); } $validator = Validator::make($c, [ 'target_type' => 'required|in:'.implode(',', array_keys(self::TARGETS)), 'purpose' => 'required|in:'.implode(',', array_keys(self::PURPOSES)), 'requested_state' => 'sometimes|in:draft,preliminary,in_validation', 'disclosure' => 'sometimes|in:restricted,public', 'external_status' => 'sometimes|in:'.implode(',', self::EXTERNAL), 'criteria' => 'sometimes|nullable|string|max:2000', 'justification' => 'sometimes|nullable|string|max:4000', 'source_reference' => 'sometimes|nullable|string|max:2000', 'citation' => 'sometimes|nullable|string|max:1000', 'document_id' => 'sometimes|nullable|uuid', 'authority_id' => 'sometimes|nullable|uuid', 'valid_from' => 'sometimes|nullable|date_format:Y-m-d', 'valid_until' => 'sometimes|nullable|date_format:Y-m-d', 'external_act_reference' => 'sometimes|nullable|string|max:2000', 'production_stage' => 'sometimes|nullable|string|max:250', ]); if ($validator->fails()) { throw ValidationException::withMessages(collect($validator->errors()->messages())->mapWithKeys(fn ($v, $k) => [$prefix.'.classification.'.$k => $v])->all()); } $kind = $c['target_type'] ?? ''; $id = (string) ($row['related_id'] ?? ''); if (($kind === 'sector' && ! ctype_digit($id)) || ($kind !== 'sector' && ! Str::isUuid($id))) { $fail('related_id', 'Identificador de referência inválido.'); } $previousRows = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? []) : []; if (! isset(self::TARGETS[$kind]) || ! $this->canRetainReference($kind, $id, $row, $previousRows)) { $fail('related_id', 'Selecione uma referência existente e consultável; nenhum cadastro é criado por esta relação.'); } $purpose = $c['purpose'] ?? ''; if (! isset(self::PURPOSES[$purpose])) { $fail('classification.purpose', 'Indique a finalidade da relação.'); } if (in_array($purpose, ['regime_recognition', 'enterprise_admission', 'specific_benefit'], true) && $kind !== 'zone') { $fail('classification.purpose', 'Regime, admissão e benefício usam a relação regulatória com a zona, separada da ficha física do parque.'); } $identity = self::TARGETS[$kind][0].':'.$row['related_id'].':'.$purpose; if (isset($seen[$identity])) { $fail('related_id', 'Esta referência e finalidade já estão 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.'); } if (! empty($c['document_id']) && ! DB::table('opportunity_documents')->where('id', $c['document_id'])->where('opportunity_id', $record->id)->whereNull('deleted_at')->exists()) { $fail('classification.document_id', 'Documento deve pertencer aos anexos desta oportunidade.'); } if (! empty($c['authority_id']) && ! DB::table('authorities')->where('id', $c['authority_id'])->where('status', 'published')->exists()) { $fail('classification.authority_id', 'Entidade competente não consta do catálogo publicado.'); } 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; a associação não concede o direito.'); } } } /** Exact persisted relation retention is not permission to choose a withdrawn target anew. */ 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; } $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))); } public function hash(array $row): string { // Client identity/status cannot certify a relation. Object-order normalization is reused. return OpportunityCanonicalValue::hash($row); } public function canReview(User $actor, Opportunity $record, string $dimension): bool { $access = app(PortalProfileAccess::class); $responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer'; return $access->canView($actor, $record) && $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category) && ! $access->conflict($actor, $record) && $access->operational($actor, 'validate', $record->id) && $access->mfa($actor) && collect($access->grants($actor, 'validator'))->contains(fn ($g) => in_array($record->opportunity_category, json_decode($g->opportunity_types, true), true) && ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true)); } public function state(Opportunity $record, array $row): string { $decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null; if ($decision && $decision['state'] !== 'confirmed') { return $decision['state']; } if ($decision) { $reviewer = User::find($decision['actor_id']); $c = $row['classification']; if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) { return 'in_validation'; } // Current grants/conflicts still apply; reading a public page never requires the reviewer's browser session. $access = app(PortalProfileAccess::class); $responsibility = self::TARGETS[$c['target_type']][2] === 'regulation' ? 'legal_reviewer' : 'technical_reviewer'; $responsible = $reviewer && collect($access->grants($reviewer, 'validator'))->contains(fn ($g) => ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($record->opportunity_category, json_decode($g->opportunity_types, true), true) && in_array($responsibility, json_decode($g->responsibilities, true), true)); if ($responsible && ! $access->conflict($reviewer, $record)) { return 'confirmed'; } return 'in_validation'; } return $row['classification']['requested_state'] ?? 'preliminary'; } private function evidenceHash(array $row): ?string { $id = $row['classification']['document_id'] ?? null; if (! $id) { return null; } $resource = DB::table('opportunity_documents as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $id) ->first(['r.id', 'r.version', 'r.deleted_at', 'd.sha256']); return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]); } public function listing(User $actor, Opportunity $record): array { app(PortalProfileAccess::class)->authorize($actor, 'view', $record); $rows = app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? []; $rows = array_values(array_filter($rows, fn ($row) => isset($row['classification']))); return array_map(function ($row) use ($record, $actor) { $kind = $row['classification']['target_type']; $dimension = self::TARGETS[$kind][2]; $metadata = $record->workflow_context['canonical_items'] ?? []; $prior = []; foreach ($metadata['deleted'] ?? [] as $item) { $old = $item['last_value'] ?? []; if (isset($old['classification']) && ($old['related_id'] ?? null) === $row['related_id'] && ($old['classification']['target_type'] ?? null) === $kind && ($old['classification']['purpose'] ?? null) === $row['classification']['purpose'] && $this->hash($old) !== $this->hash($row)) { $prior[] = ['metadata' => array_diff_key($item, ['last_value' => true]), 'history' => $record->workflow_context['classification_decisions'][$this->hash($old)]['history'] ?? [], 'relation' => $old]; } } 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, 'can_review' => $this->canReview($actor, $record, $dimension), 'history' => $record->workflow_context['classification_decisions'][$this->hash($row)]['history'] ?? []]; }, $rows); } 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(); return DB::transaction(function () use ($actor, $record, $data) { $record = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail(); abort_if($record->status === 'archived', 409, 'Oportunidade arquivada; preserve o histórico e use o workflow aprovado.'); $access = app(PortalProfileAccess::class); $access->authorize($actor, 'view', $record); abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A oportunidade mudou. Atualize antes de decidir; o rascunho permanece guardado.'); $rows = app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? []; // A revocation also reaches a previously published version after a draft correction. 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']); 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.'); if ($data['state'] === 'confirmed') { foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) { abort_if(empty(trim($c[$key] ?? '')), 422, 'Confirmação exige critérios, justificação, fonte e citação por associação. A preparação pode continuar como preliminar.'); } $this->validate(['relations' => [$row]], $record); } $context = $record->workflow_context ?? []; $before = $context['classification_decisions'][$data['hash']] ?? null; $decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'], 'evidence_hash' => $this->evidenceHash($row), 'version' => ($before['version'] ?? 0) + 1]; $context['classification_decisions'][$data['hash']] = [...$decision, 'history' => [...($before['history'] ?? []), $decision]]; $record->workflow_context = $context; $record->management_version++; $record->save(); $access->audit($actor, 'classification_'.$data['state'], ['record_id' => $record->id, 'relation_hash' => $data['hash'], 'before' => $before, 'after' => $decision]); return $record; }); } /** Frozen at the existing editorial publication, not on draft save or association confirmation. */ public function publication(array $payload): array { return array_values(array_filter($payload['relations'] ?? [], fn ($r) => isset($r['classification']) && ($r['classification']['disclosure'] ?? 'restricted') === 'public')); } 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') { return []; } $rows = []; foreach ($record->workflow_context['publication']['classification'] ?? [] as $row) { $c = $row['classification']; $kind = $c['target_type']; if (! isset(self::TARGETS[$kind]) || ($c['disclosure'] ?? '') !== 'public') { continue; } $target = $this->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; } $state = $this->state($record, $row); if (in_array($state, ['archived', 'revoked'], true)) { continue; } // 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 ?? []) : [], '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, 'production_stage' => $c['production_stage'] ?? null, 'valid_from' => $c['valid_from'] ?? null, 'valid_until' => $c['valid_until'] ?? null]; } return $rows; } /** Same public projection for count/list/map; AND across filters, OR across selected associations. */ public function constrain(Builder $query, array $filters): void { $keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id']; if (! array_filter(array_intersect_key($filters, array_flip($keys)), fn ($v) => $v !== null && $v !== '')) { return; } // Query candidates in bounded chunks to apply the exact visibility/decision rules used by detail. // IDs only, no private payload leaves this service. A future indexed projection is an optimization, not another catalogue. $ids = []; (clone $query)->reorder()->select('opportunities.*')->chunkById(200, function ($records) use (&$ids, $filters) { foreach ($records as $record) { $rows = $this->publicRows($record); $main = array_filter(array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_state', 'association_purpose'])), fn ($v) => $v !== null && $v !== ''); $matches = $main === [] || collect($rows)->contains(function ($r) use ($main) { foreach (['association_type' => 'target_type', 'association_target' => 'target_id', 'association_state' => 'state', 'association_purpose' => 'purpose'] as $filter => $field) { if (isset($main[$filter]) && ($r[$field] ?? null) !== $main[$filter]) { return false; } } return true; }); if (! empty($filters['chain_id'])) { $matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === 'chain' && $r['target_id'] === $filters['chain_id']); } if (! empty($filters['authority_id'])) { $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'])); } if ($matches) { $ids[] = $record->id; } } }, 'opportunities.id', 'id'); $query->whereIn('opportunities.id', $ids); } 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']) ->map(fn ($r) => ['id' => $r->id, 'reference' => $r->reference, 'reason' => 'Título exato normalizado; identidade do negócio por determinar. Nenhuma fusão executada.'])->all(); } }