enabled()) { $errors['canonical.opportunity_category'] = 'Categoria fora desta entrega: concessão, financiamento, fornecimento e outros permanecem indisponíveis. Os registos anteriores são preservados.'; } if (($payload['opportunity_category'] ?? '') === 'real_estate') { $property = $payload['real_estate'] ?? []; foreach (['construction_status' => ['completed'], 'occupancy_status' => ['vacant'], 'transaction_type' => ['sale', 'lease']] as $field => $values) { if (! empty($property[$field]) && ! in_array($property[$field], $values, true)) { $errors['canonical.real_estate.'.$field] = 'Variante indisponível para submissão nesta entrega. Pode guardar e recuperar a preparação; estão incluídas venda e arrendamento de imóvel concluído e desocupado.'; } } if (array_filter($property['encumbrances'] ?? [], fn ($row) => ! empty($row['type']) && $row['type'] !== 'none')) { $errors['canonical.real_estate.encumbrances'] = 'Imóvel com encargos: pode preparar e guardar; a submissão deste ramo não está incluída nesta entrega.'; } if (! empty($property['max_built_area']) || ! empty($property['urban_limit_document_id'])) { $errors['canonical.real_estate.max_built_area'] = 'Limitações urbanísticas adicionais: preparação disponível; este ramo de submissão aguarda demonstração própria.'; } } return $errors; } public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1'; /** @return array */ public function schema(bool $draft = false, array $payload = []): array { $bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json')); if ($bytes === false) { throw new \RuntimeException('Opportunity schema unavailable.'); } $schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR); // The original conditions were omitted from the extracted Create/Patch definitions. foreach (['opportunity', 'realEstate', 'landRights', 'businessTransaction'] as $name) { $schema['$defs'][$name.'Create']['allOf'] = $schema['$defs'][$name]['allOf'] ?? []; } $schema = app(OpportunityCategoryFields::class)->enrich($schema); $schema = app(OpportunityModularProfiles::class)->extend($schema); $schema = app(OpportunityClassification::class)->extend($schema); $schema = app(OpportunityInitiativeApplicability::class)->schema($schema, $payload); $schema = $this->writeSchema($schema, $draft); $properties = &$schema['$defs']['opportunityCreate']['properties']; unset($properties['mutation_context']); // Actor, audit reason and revision are server-owned. $properties['sector_id'] = ['type' => 'integer', 'minimum' => 1]; if (! app(OpportunityInitiativeApplicability::class)->enabled($payload)) { $properties['promoter_id'] = ['type' => ['string', 'integer']]; } // References point to the existing collections; their metadata is recovered on the server. foreach (['locations', 'documents', 'images'] as $field) { $properties[$field]['items'] = ['type' => 'string', 'format' => 'uuid']; } $properties['evidences'] = ['type' => 'array', 'items' => ['$ref' => '#/$defs/evidenceCreate']]; if (! $draft) { $properties['evidences']['minItems'] = 1; } $properties['evidences']['items'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => ['title' => ['type' => 'string'], 'evidence_type' => ['type' => 'string'], 'document_id' => ['type' => 'string', 'format' => 'uuid'], 'description' => ['type' => 'string']], ...($draft ? [] : ['required' => ['title', 'evidence_type', 'document_id']])]; $schema['$ref'] = '#/$defs/opportunityCreate'; $schema['$id'] = 'urn:investmoz:'.self::VERSION.($draft ? ':draft' : ':submit'); return $schema; } /** * @param array $node * @return array */ private function writeSchema(array $node, bool $draft): array { if (isset($node['$ref']) && preg_match('~^#/\$defs/(.+)$~', $node['$ref'], $match)) { // Collections in the normative Create still referenced response definitions. $defs = ['product', 'asset', 'input', 'market', 'impact', 'moneyItem', 'milestone', 'license', 'risk', 'relation', 'employment', 'environmentalSocial', 'localizedPublication', 'realEstate', 'landRights', 'businessTransaction', 'parcel', 'improvement', 'community']; if (in_array($match[1], $defs, true)) { $node['$ref'] .= 'Create'; } } if ($draft) { unset($node['required'], $node['minItems'], $node['minProperties'], $node['contains'], $node['allOf']); if (isset($node['type'])) { $node['type'] = array_values(array_unique([...(array) $node['type'], 'null'])); } if (isset($node['enum']) && ! in_array(null, $node['enum'], true)) { $node['enum'][] = null; } if (isset($node['minLength'])) { $node['minLength'] = 0; } if (($node['const'] ?? null) === true) { unset($node['const']); $node['type'] = 'boolean'; } } foreach ($node as $key => $value) { if (is_array($value)) { if (in_array($key, ['properties', '$defs'], true)) { foreach ($value as $name => $child) { $value[$name] = $this->writeSchema($child, $draft); } $node[$key] = $value; } elseif (in_array($key, ['items', 'if', 'then', 'else'], true)) { $node[$key] = $this->writeSchema($value, $draft); } elseif (in_array($key, ['allOf', 'oneOf', 'anyOf'], true)) { $node[$key] = array_map(fn ($child) => $this->writeSchema($child, $draft), $value); } } } return $node; } /** * @param array $payload * @return array */ public function draft(array $payload): array { abort_unless(config('portal.opportunity_specialized_drafts_enabled'), 422, 'Contrato especializado indisponível.'); $this->validateSchema($payload, true); return $payload; } /** @param array $payload */ private function validateSchema(array $payload, bool $draft): void { $schema = $this->schema($draft, $payload); $validator = new CompliantValidator(null, 100, false); $data = $this->schemaData($payload, $schema['$defs']['opportunityCreate'], $schema['$defs']); $result = $validator->validate($data, json_decode(json_encode($schema, JSON_THROW_ON_ERROR))); if ($error = $result->error()) { throw ValidationException::withMessages($this->schemaValidationErrors($error)); } } /** @return array> */ private function schemaValidationErrors(ValidationError $error): array { $errors = []; $formatter = new ErrorFormatter; $collect = function (ValidationError $node) use (&$collect, &$errors, $formatter): void { if ($node->subErrors() !== []) { foreach ($node->subErrors() as $child) { $collect($child); } return; } $prefix = rtrim('canonical.'.implode('.', $node->data()->fullPath()), '.'); $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; } if ($node->keyword() === 'required') { foreach ($args['missing'] ?? [] as $field) { $errors[$prefix.'.'.$field][] = 'Preencha este campo antes de submeter.'; } return; } if ($node->keyword() === 'additionalProperties') { $definition = $node->schema()->info()->data(); $declared = is_object($definition) ? (array) ($definition->properties ?? []) : []; foreach ($args['properties'] ?? [] as $field) { // Opis can report declared properties as unchecked after a sibling field fails. // Keep real unknown-property errors without turning valid fields into errors. if (! array_key_exists($field, $declared)) { $errors[$prefix.'.'.$field][] = 'Campo não previsto no contrato desta categoria.'; } } return; } if ($node->keyword() === 'minLength') { $errors[$prefix][] = 'Preencha este campo com pelo menos '.$args['min'].' caracteres; tem '.$args['length'].'.'; return; } if ($node->keyword() === 'maxLength') { $errors[$prefix][] = 'Este campo admite até '.$args['max'].' caracteres; tem '.$args['length'].'.'; return; } foreach ($formatter->format($node) as $path => $messages) { $key = rtrim('canonical'.str_replace('/', '.', $path), '.'); $errors[$key] = array_values(array_unique([...($errors[$key] ?? []), ...$messages])); } }; $collect($error); // Formatting must never make an invalid schema result acceptable. return $errors ?: ['canonical' => ['Não foi possível validar o contrato. Reveja os campos preenchidos.']]; } /** @param array $node * @param array $defs */ private function schemaData(mixed $value, array $node, array $defs): mixed { if (($node['x-investmoz-classification'] ?? false) && is_array($value)) { $node = isset($value['classification']) ? $defs['relationCreate'] : ['type' => 'object']; } if (isset($node['$ref'])) { $name = basename($node['$ref']); $node = $defs[$name] ?? $node; } if (! is_array($value)) { return $value; } if (in_array('object', (array) ($node['type'] ?? []), true) || isset($node['properties'])) { $result = new \stdClass; foreach ($value as $key => $item) { $result->{$key} = $this->schemaData($item, $node['properties'][$key] ?? [], $defs); } return $result; } return array_map(fn ($item) => $this->schemaData($item, $node['items'] ?? [], $defs), $value); } /** @return array */ public function validateSubmission(Opportunity $record): array { $values = app(OpportunityManagement::class)->values($record); $payload = $values['canonical'] ?? []; $errors = []; $initiative = app(OpportunityInitiativeApplicability::class); $initiative->validate($payload, $errors); try { $this->validateSchema($payload, false); } catch (ValidationException $failure) { throw ValidationException::withMessages([...$failure->errors(), ...$errors]); } $check = function (bool $valid, string $path, string $message) use (&$errors): void { if (! $valid) { $errors['canonical.'.$path] = $message; } }; $check(($payload['opportunity_category'] ?? null) === $record->opportunity_category, 'opportunity_category', 'A categoria deve coincidir com o registo e a autorização.'); $check($initiative->enabled($payload) || ($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Identifique o promotor real da oportunidade; o operador do portal não o substitui.'); $previous = app(OpportunityManagement::class)->values($record)['canonical'] ?? []; $sameTaxonomy = ($previous['sector_id'] ?? null) === ($payload['sector_id'] ?? null) && ($previous['subsector_id'] ?? null) === ($payload['subsector_id'] ?? null) && ($previous['economic_activity_id'] ?? null) === ($payload['economic_activity_id'] ?? null); $check(app(EconomicReferenceCatalog::class)->validHierarchy((int) $payload['sector_id'], $payload['subsector_id'] ?? null, $payload['economic_activity_id'] ?? null, $sameTaxonomy), 'economic_activity_id', 'Sector, subsector e atividade devem corresponder à hierarquia/versão publicada; os cinco níveis originais são conservados.'); foreach (['internal_contact_id', 'public_contact_id'] as $field) { if (! empty($payload[$field])) { $contacts = DB::table('contacts')->where('contacts.id', $payload[$field])->whereNull('contacts.deleted_at'); if ($record->organization_id) { $contacts->whereExists(fn ($q) => $q->selectRaw('1')->from('investor_contacts as ic')->join('investors as i', 'i.id', '=', 'ic.investor_id') ->whereColumn('ic.contact_id', 'contacts.id')->where('i.organization_id', $record->organization_id)->whereNull('i.deleted_at')); } else { $contacts->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$record->owner_user_id); } $check($contacts->exists(), $field, 'Contacto inexistente ou sem relação com o Promotor.'); } } $resources = []; foreach (['locations', 'documents', 'images'] as $kind) { $table = app(OpportunityResources::class)->table($kind); foreach ($payload[$kind] ?? [] as $index => $id) { $row = DB::table($table)->where('id', $id)->where('opportunity_id', $record->id)->whereNull('deleted_at')->first(); $check($row !== null, "$kind.$index", 'Recurso inexistente, eliminado ou de outra oportunidade.'); if (! $row) { continue; } $row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR); if ($kind !== 'locations') { $file = DB::table('documents')->where('id', $row->document_id)->first(); $check($file !== null && $file->deleted_at === null && $file->status === 'active' && $file->malware_scan_status === 'clean' && 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(VerifiedDocumentBinary::class)->respond($file); $row->sha256 = trim($file->sha256); } } else { $location = $row->payload; // §10.2 area/unit consistency applies at submission. Resource drafts may // retain incomplete measurements; never invent a unit or convert values. $hasArea = isset($location['area_value']); $hasUnit = isset($location['area_unit']) && $location['area_unit'] !== ''; $check(! $hasArea || $hasUnit, "locations.$index.area_unit", 'Indique a unidade da área antes de submeter; a preparação incompleta continua guardada nos recursos.'); $check(! $hasUnit || $hasArea, "locations.$index.area_value", 'Indique o valor da área antes de submeter; a unidade não determina o valor.'); $check(! $hasUnit || in_array($location['area_unit'], OpportunityResources::AREA_UNITS, true), "locations.$index.area_unit", 'Use uma unidade do catálogo existente: sqm, ha ou sqkm.'); $check(! $initiative->applicable($payload, 'physical_site') || (! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location)), "locations.$index", 'Localização requer distrito, papel e indicação de principal.'); app(InvestmentGeography::class)->selection($location, true); $country = $location['country_code'] ?? null; $check($country === 'MZ', "locations.$index.country_code", 'A hierarquia administrativa moçambicana deve declarar MZ; outros países exigem localização própria.'); } $resources[$kind][$id] = (array) $row; } } $locations = $resources['locations'] ?? []; $primary = array_filter($locations, fn ($l) => $l['payload']['is_primary'] ?? false); $check(! $initiative->applicable($payload, 'physical_site') || count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.'); $check(! $initiative->applicable($payload, 'physical_site') || ($payload['location_mode'] ?? null) !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.'); foreach ($payload['evidences'] as $i => $evidence) { $check(isset($resources['documents'][$evidence['document_id']]), "evidences.$i.document_id", 'Evidência deve usar um documento ativo desta oportunidade incluído no contrato.'); } $this->categoryRules($payload, $record, $resources, $errors); app(OpportunityCategoryFields::class)->validate($payload, $record, $resources, $errors); if ($errors) { throw ValidationException::withMessages($errors); } ksort($payload); $snapshot = ['adapter_version' => self::VERSION, 'schema_sha256' => hash_file('sha256', resource_path('data/investmoz-opportunity-modular.schema.json')), 'payload' => $payload, 'resources' => $resources]; if (isset(OpportunityModularProfiles::PROFILES[$record->opportunity_category]) || ($payload['module_profile_version'] ?? '') === OpportunityModularProfiles::HISTORY_VERSION) { $snapshot['qa_extension'] = ['version' => OpportunityModularProfiles::VERSION, 'profile_version' => $payload['module_profile_version'], 'functional_decision' => 'Experimental QA proposal; functional approval of modular contracts and SLA remains pending. Legacy integration authorization does not approve these modules.', 'gates' => $this->gates($record)]; if ($record->opportunity_category === 'other') { $snapshot['qa_extension']['subtype'] = config('portal.opportunity_other_qa_subtypes.'.($payload['module_details']['subtype'] ?? ''), []); } // A documentary correction must not rewrite the checksum of an already // signed, unchanged version. Its historical annotation is explicitly // reconciled by the read adapter; new/changed content uses the current note. $prior = $record->workflow_context['submission'] ?? []; if (isset($prior['qa_extension']) && ($prior['payload'] ?? null) == $snapshot['payload'] && ($prior['resources'] ?? null) == $snapshot['resources']) { $snapshot['qa_extension']['functional_decision'] = $prior['qa_extension']['functional_decision']; } } $snapshot['hash'] = hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR)); return $snapshot; } /** * @param array $p * @param array $resources * @param array $errors */ private function categoryRules(array $p, Opportunity $record, array $resources, array &$errors): void { $category = $p['opportunity_category']; app(OpportunityModularProfiles::class)->validate($p, $resources, $errors); foreach (['real_estate', 'land_rights', 'business_transaction'] as $module) { $applicable = match ($module) { 'real_estate' => ['real_estate'], 'land_rights' => ['land_rights'], default => ['business_sale', 'business_partnership', 'franchise'] }; if (isset($p[$module]) && ! in_array($category, $applicable, true)) { $errors['canonical.'.$module] = 'Módulo incompatível com a categoria. Preserve-o noutra versão antes de mudar de categoria.'; } } if ($category === 'investment_project' && app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment')) { $total = $p['total_investment']; $sum = array_sum(array_column($p['investment_breakdown'], 'amount')); $secured = array_sum(array_column($p['secured_financing'], 'amount')); if (abs($sum - $total) > 0.010001 || $secured > $total || abs($p['funding_sought'] - ($total - $secured)) > 0.010001) { $errors['canonical.investment_breakdown'] = 'Rubricas e financiamento devem reconciliar com o total (tolerância normativa 0,01).'; } if (! in_array($p['maturity_stage'], array_keys(OpportunityMaturity::STAGES), true)) { $errors['canonical.maturity_stage'] = 'Use um código explícito do catálogo de oito etapas; não reinterpretar a escala histórica.'; } foreach (['investment_breakdown', 'secured_financing'] as $field) { foreach ($p[$field] as $i => $amount) { if ($amount['currency'] !== $p['currency']) { $errors["canonical.$field.$i.currency"] = 'Moedas distintas requerem cenário/câmbio explícito; não somar valores de moedas diferentes.'; } } } foreach ($p['markets'] as $i => $market) { if (isset($market['country_code']) && Validator::make(['country' => $market['country_code']], ['country' => app(CountryCatalogue::class)->rule()])->fails()) { $errors["canonical.markets.$i.country_code"] = 'Use país do catálogo ISO existente.'; } } } if ($category === 'real_estate') { $re = $p['real_estate']; if ($re['transaction_type'] === 'sale' && ($re['price'] ?? 0) <= 0) { $errors['canonical.real_estate.price'] = 'Venda exige preço positivo.'; } if ($re['transaction_type'] === 'lease') { if (($re['rent'] ?? 0) <= 0) { $errors['canonical.real_estate.rent'] = 'Arrendamento exige renda positiva.'; } if (empty($re['rent_periodicity'])) { $errors['canonical.real_estate.rent_periodicity'] = 'Indique a periodicidade da renda.'; } } if (in_array($re['construction_status'], ['completed', 'occupied', 'renovation'], true) && empty($re['property_condition'])) { $errors['canonical.real_estate.property_condition'] = 'Imóvel existente exige condição identificada.'; } if (in_array($re['construction_status'], ['planned', 'under_construction'], true) && empty($re['planning_permit_document_id'])) { $errors['canonical.real_estate.planning_permit_document_id'] = 'Estado construtivo exige licença de construção (§25.1).'; } if (in_array($re['construction_status'], ['completed', 'occupied', 'renovation'], true) && empty($re['use_license_document_id'])) { $errors['canonical.real_estate.use_license_document_id'] = 'Imóvel existente exige licença de utilização aplicável (§25.1).'; } foreach (['planning_permit_document_id', 'use_license_document_id'] as $field) { if (! empty($re[$field]) && ! isset($resources['documents'][$re[$field]])) { $errors['canonical.real_estate.'.$field] = 'Licença deve referir documento ativo desta oportunidade.'; } } } if ($category === 'land_rights') { $land = $p['land_rights']; if (! empty($land['issuing_authority_id']) && ! DB::table('authorities')->where('id', $land['issuing_authority_id'])->where('status', 'published')->exists()) { $errors['canonical.land_rights.issuing_authority_id'] = 'Entidade emissora deve usar autoridade canónica publicada.'; } if (! empty($land['issue_date']) && ($land['issue_date'] > today()->toDateString() || (! empty($land['expiry_date']) && $land['expiry_date'] <= $land['issue_date']))) { $errors['canonical.land_rights.issue_date'] = 'Emissão não futura e validade posterior à emissão.'; } foreach ($land['parcels'] as $i => $parcel) { if (! isset($resources['locations'][$parcel['location_id']])) { $errors["canonical.land_rights.parcels.$i.location_id"] = 'Parcela deve usar uma localização desta oportunidade.'; } try { $g = DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_GeometryType(g)=\'ST_MultiPolygon\' AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) s', [json_encode($parcel['geometry'])]); if (! $g?->valid) { throw new \RuntimeException; } } catch (\Throwable) { $errors["canonical.land_rights.parcels.$i.geometry"] = 'Parcela requer MultiPolygon válido, não vazio, EPSG:4326; limite administrativo não é parcela.'; } } if (in_array($land['duat_status'], ['provisional', 'definitive'], true) && empty($land['duat_number'])) { $errors['canonical.land_rights.duat_number'] = 'DUAT provisório/definitivo exige número e entidade emissora.'; } } if (isset($p['business_transaction'])) { $business = $p['business_transaction']; if ($business['legal_entity_id'] !== $record->organization_id || ! DB::table('organizations')->where('id', $business['legal_entity_id'])->whereNull('deleted_at')->exists()) { $errors['canonical.business_transaction.legal_entity_id'] = 'Entidade ativa representada obrigatória.'; } } } /** @param array $snapshot */ public function validatePublication(array $snapshot): void { $p = $snapshot['payload']; $errors = []; app(OpportunityCategoryFields::class)->publication($p, $errors); app(OpportunityModularProfiles::class)->publication($p, $errors); foreach ($snapshot['resources']['documents'] ?? [] as $id => $document) { if (! empty($document['payload']['valid_until']) && $document['payload']['valid_until'] < today()->toDateString()) { $errors['documents.'.$id] = 'Documento incluído expirou; preserve o original e reveja a versão.'; } } if ($p['opportunity_category'] === 'land_rights') { foreach ($p['land_rights']['required_authorizations'] as $i => $authorization) { if (! in_array($authorization['status'] ?? '', ['approved', 'not_required'], true) || (($authorization['status'] ?? '') === 'approved' && ! isset($snapshot['resources']['documents'][$authorization['document_id'] ?? '']))) { $errors['land_rights.required_authorizations.'.$i] = 'Autorização externa pendente ou sem documento. Aprovação interna não substitui esse ato.'; } } foreach ($p['land_rights']['overlaps_conflicts'] as $i => $conflict) { if (($conflict['type'] ?? '') !== 'none' && ($conflict['status'] ?? '') !== 'resolved') { $errors['land_rights.overlaps_conflicts.'.$i] = 'Conflito impeditivo deve ser resolvido com evidência antes de publicar.'; } } } if ($p['visibility'] === 'public') { $images = $snapshot['resources']['images'] ?? []; if (count(array_filter($images, fn ($image) => $image['payload']['is_cover'] ?? false)) !== 1) { $errors['images'] = 'Publicação pública exige uma capa explícita.'; } foreach ($images as $id => $image) { $meta = $image['payload']; if (empty($meta['alt_text']) || empty($meta['caption']) || empty($meta['rights_status']) || empty($meta['derivatives']) || (! empty($meta['rights_valid_until']) && $meta['rights_valid_until'] < today()->toDateString())) { $errors['images.'.$id] = 'Legenda, texto alternativo, direitos válidos e derivadas verificadas obrigatórios.'; } foreach ($meta['derivatives'] ?? [] as $derived) { $file = DB::table('documents')->where('id', $derived['document_id'])->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->first(); if (! $file) { $errors['images.'.$id] = 'Scan de uma derivada ainda não demonstrado.'; } else { app(VerifiedDocumentBinary::class)->respond($file); } } } } if ($errors) { throw ValidationException::withMessages($errors); } } /** @return list */ public function gates(Opportunity $record): array { return match ($record->opportunity_category) { 'investment_project' => ['technical_review', 'legal_territorial_review'], 'real_estate' => ['property_review', 'registry_urban_review', 'building_compliance_review'], 'land_rights' => ['cadastral_screening', 'land_legal_review', 'community_environment_review', 'competent_authority_clearance'], 'business_sale' => ['corporate_screening', 'financial_review', 'legal_tax_due_diligence', 'confidentiality_clearance'], 'business_partnership' => ['corporate_screening', 'financial_review', 'partnership_governance_review', 'legal_tax_due_diligence'], 'franchise' => ['brand_license_review', 'financial_review', 'franchise_legal_review', 'confidentiality_clearance'], default => app(OpportunityModularProfiles::class)->gates($record), }; } /** Same exact projection for preview and publication; never expose private business analysis. * @param array $payload * @return array{title:string,summary:string,description:string} */ public function publicProjection(array $payload): array { $business = isset($payload['business_transaction']) && ($payload['visibility'] ?? '') === 'public'; $label = match ($payload['module_details']['mode'] ?? '') { 'intention_call' => 'Intenção/chamada em preparação — não é direito atribuído. ', 'awarded_right' => 'Concessão com ato competente documentado. ', 'seeking_funding' => 'Procura de financiamento. ', 'funding_offer' => 'Oferta de financiamento — sujeita a análise do beneficiário. ', 'purchase_request' => 'Pedido de compra. ', 'supply_offer' => 'Oferta de fornecimento. ', default => '', }; if (($payload['module_details']['conditions_status'] ?? '') === 'proposed') { $label .= 'Valores e condições propostos, sem compromisso assumido. '; } return ['title' => (string) ($payload['official_title'] ?? ''), 'summary' => Str::limit($label.(string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['executive_summary'] ?? '')), 499, '…'), 'description' => (string) ($business ? ($payload['business_transaction']['teaser'] ?? '') : ($payload['demand_statement'] ?? ''))]; } /** @return array */ public function options(User $actor, ?Opportunity $record): array { $orgs = array_values(array_unique(array_column(app(PortalProfileAccess::class)->navigation($actor)['contexts'] ?? [], 'organization_id'))); if (! $record && (app(PortalProfileAccess::class)->navigation($actor)['internalPromoter'] ?? false)) { // A staff creation scope does not expose unrelated private contact lists. $orgs = []; } if ($record?->organization_id) { $orgs = [$record->organization_id]; } $contacts = DB::table('contacts')->whereNull('contacts.deleted_at')->where(function ($q) use ($orgs, $actor) { $q->where(fn ($own) => $own->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$actor->id)) ->orWhereExists(fn ($i) => $i->selectRaw('1')->from('investor_contacts as ic')->join('investors as inv', 'inv.id', '=', 'ic.investor_id')->whereColumn('ic.contact_id', 'contacts.id')->whereIn('inv.organization_id', $orgs)->whereNull('inv.deleted_at')); })->get(['id', 'full_name']); $submissionProperties = $this->schema(false)['$defs']['opportunityCreate']['properties']; return ['version' => self::VERSION, 'modularProfiles' => app(OpportunityModularProfiles::class)->enabled() ? OpportunityModularProfiles::PROFILES : [], 'historyVersion' => app(OpportunityModularProfiles::class)->enabled() ? OpportunityModularProfiles::HISTORY_VERSION : null, 'classification' => app(OpportunityClassification::class)->options(), 'schema' => $this->schema(true)['$defs']['opportunityCreate'], 'definitions' => $this->schema(true)['$defs'], 'submissionTextLimits' => ['demand_statement' => array_intersect_key($submissionProperties['demand_statement'], array_flip(['minLength', 'maxLength']))], 'activities' => app(EconomicReferenceCatalog::class)->activities($record ? array_values(array_filter(array_intersect_key(app(OpportunityManagement::class)->values($record)['canonical'] ?? [], array_flip(['subsector_id', 'economic_activity_id'])))) : []), 'contacts' => $contacts, 'contactInvestors' => $record?->organization_id ? DB::table('investors')->where('organization_id', $record->organization_id)->whereNull('deleted_at')->whereIn('status', ['prospect', 'active'])->get(['id', 'legal_name']) : [], 'countries' => app(CountryCatalogue::class)->all(), 'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']), 'gates' => $record ? $this->gates($record) : [], 'resources' => $record ? array_combine(['locations', 'documents', 'images'], array_map(fn ($k) => array_values(array_filter(app(OpportunityResources::class)->listing($record->id, $k), fn ($row) => empty($row->deleted_at))), ['locations', 'documents', 'images'])) : []]; } }