*/ private function text(): array { return ['type' => 'string', 'minLength' => 1, 'maxLength' => 1000]; } /** @return array */ private function doc(): array { return ['type' => 'string', 'format' => 'uuid']; } /** @return array */ private function money(): array { return ['type' => 'number', 'minimum' => 0]; } /** @param array $properties * @param list $required * @return array */ private function object(array $properties, array $required = []): array { return ['type' => 'object', 'properties' => $properties, 'required' => $required, 'additionalProperties' => true]; } /** @param array $properties * @param list $required * @return array */ private function rows(array $properties, array $required = []): array { return ['type' => 'array', 'items' => $this->object($properties, $required)]; } /** Preserve normative artifact and legacy keys; enrich its runtime adapter explicitly. * @param array $schema * @return array */ public function enrich(array $schema): array { $s = $this->text(); $d = $this->doc(); $n = $this->money(); $date = ['type' => 'string', 'format' => 'date']; $strings = ['type' => 'array', 'items' => $s]; $bool = ['type' => 'boolean']; $valuation = $this->object(['value' => $n, 'date' => $date, 'method' => $s, 'appraiser' => $s, 'document_id' => $d, 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'divergence_justification' => $s], ['value', 'date', 'method', 'appraiser', 'document_id']); $defs = &$schema['$defs']; $common = &$defs['opportunityCreate']['properties']; foreach (['markets' => 'marketCreate', 'risks' => 'riskCreate', 'licenses' => 'licenseCreate', 'milestones' => 'milestoneCreate'] as $field => $def) { $common[$field]['items'] = ['$ref' => '#/$defs/'.$def]; } // Reuse the existing canonical object definition in both editor and validation. // The extracted Create property was opaque, preventing completion through the UI. $common['environmental_social'] = ['$ref' => '#/$defs/environmentalSocialCreate']; $common['maturity_stage']['enum'] = [...array_keys(OpportunityMaturity::STAGES), null]; $common['financial_indicators'] = $this->object(['npv' => ['type' => 'number'], 'irr' => ['type' => 'number'], 'payback_years' => $n, 'scenario' => $s, 'horizon_years' => ['type' => 'integer', 'minimum' => 1], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'discount_rate' => $n, 'model_document_id' => $d]); // OP-9f173ed3-0043: a historical scenario is not a completed feasibility model. $defs['opportunityCreate']['allOf'][] = [ 'if' => ['properties' => ['maturity_stage' => ['enum' => ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational']]], 'required' => ['maturity_stage']], 'then' => ['required' => ['financial_indicators'], 'properties' => ['financial_indicators' => ['required' => ['npv', 'irr', 'payback_years', 'scenario', 'horizon_years', 'currency', 'discount_rate', 'model_document_id']]]], ]; $defs['milestoneCreate']['properties'] += ['start_date' => $date, 'end_date' => $date, 'responsible_name' => $s, 'dependencies' => $strings]; $defs['milestoneCreate']['required'] = array_values(array_unique([...$defs['milestoneCreate']['required'], 'start_date', 'end_date', 'responsible_name'])); $defs['riskCreate']['properties']['mitigation']['minLength'] = 1; $re = &$defs['realEstateCreate']['properties']; $re['valuation'] = $valuation; $re['utilities'] = $this->rows(['type' => ['enum' => ['electricity', 'water', 'sanitation', 'telecommunications', 'access']], 'status' => ['enum' => ['existing', 'planned', 'unavailable']]], ['type', 'status']); $re['encumbrances'] = $this->rows(['type' => $s, 'details' => $s, 'document_id' => $d], ['type']); $re += ['title_document_id' => $d, 'title_holder_name' => $s, 'issuing_authority_id' => $d, 'expected_completion_date' => $date, 'occupancy_details' => $s, 'occupancy_document_id' => $d, 'amenities_other' => $s, 'urban_limit_document_id' => $d, 'max_built_area' => ['type' => 'number', 'exclusiveMinimum' => 0]]; $re['viewing'] = $this->object(['mode' => $s, 'contact_id' => $d, 'window' => $s, 'instructions' => $s]); $defs['realEstateCreate']['required'] = [...$defs['realEstateCreate']['required'], 'title_document_id', 'title_holder_name', 'issuing_authority_id']; $lr = &$defs['landRightsCreate']['properties']; $lr['overlaps_conflicts'] = $this->rows(['type' => $s, 'status' => $s, 'details' => $s, 'resolution_document_id' => $d], ['type', 'status']); $lr['required_authorizations'] = $this->rows(['name' => $s, 'authority_id' => $d, 'status' => ['enum' => ['pending', 'approved', 'rejected', 'not_required']], 'document_id' => $d, 'condition' => $s], ['name', 'status']); $lr['community_consultation'] = $this->object(['status' => $s, 'minutes_document_id' => $d, 'participants' => $strings, 'map_document_id' => $d, 'commitments' => $s, 'date' => $date], ['status', 'minutes_document_id', 'participants', 'map_document_id', 'commitments', 'date']); $lr['consideration'] = $this->object(['object' => ['enum' => ['improvements', 'assets', 'operation', 'partnership']], 'amount' => $n, 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$']], ['object', 'amount', 'currency']); $lr += ['holder_document_id' => $d, 'communities_affected' => $bool, 'exploitation_plan_required' => $bool]; // Explicit optional unit of the parcel's existing area. Older versions stay unknown; // do not assign the location's unit or convert the recorded numeric area. $defs['parcelCreate']['properties']['area_unit'] = ['enum' => [...OpportunityResources::AREA_UNITS, null]]; $defs['landRightsCreate']['required'] = [...$defs['landRightsCreate']['required'], 'holder_document_id', 'communities_affected', 'exploitation_plan_required']; $bt = &$defs['businessTransactionCreate']['properties']; $bt['valuation'] = $valuation; $bt['entity_registration'] = $this->object(['tax_identifier' => $s, 'registration_number' => $s, 'legal_document_id' => $d, 'beneficial_owner_document_id' => $d], ['tax_identifier', 'registration_number', 'legal_document_id', 'beneficial_owner_document_id']); $bt['financial_periods'] = $this->rows(['year' => ['type' => 'integer', 'minimum' => 1900], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'revenue' => $n, 'profit' => ['type' => 'number'], 'ebitda' => ['type' => 'number'], 'assets' => $n, 'liabilities' => $n, 'cash_flow' => ['type' => 'number'], 'document_id' => $d], ['year', 'currency', 'revenue', 'profit', 'assets', 'liabilities', 'cash_flow', 'document_id']); $bt['assets_liabilities'] = $this->object(['assets' => $s, 'excluded_assets' => $s, 'liabilities' => $s, 'guarantees' => $s, 'reconciliation_document_id' => $d], ['assets', 'excluded_assets', 'liabilities', 'guarantees', 'reconciliation_document_id']); $bt['employees'] = $this->object(['total' => ['type' => 'integer', 'minimum' => 0], 'contracts' => $s, 'obligations' => $s, 'labor_liabilities' => $s], ['total', 'contracts', 'obligations', 'labor_liabilities']); $bt['contracts_licenses'] = $this->rows(['name' => $s, 'transferability' => ['enum' => ['transferable', 'consent_required', 'not_transferable']], 'expiry_date' => $date, 'consent_document_id' => $d, 'document_id' => $d], ['name', 'transferability', 'expiry_date', 'document_id']); $bt['litigation_contingencies'] = $this->rows(['type' => $s, 'details' => $s, 'value' => $n, 'status' => $s, 'probability' => ['type' => 'number', 'minimum' => 0, 'maximum' => 100], 'entity' => $s, 'document_id' => $d], ['type']); $bt['partner_contribution'] = $this->object(['capital' => $n, 'assets' => $s, 'technology' => $s, 'market' => $s, 'management' => $s, 'calendar' => $s], ['capital', 'assets', 'technology', 'market', 'management', 'calendar']); $bt['governance_rights'] = $this->object(['voting' => $s, 'board' => $s, 'reserved_matters' => $s, 'dividends' => $s, 'exit' => $s, 'deadlock' => $s], ['voting', 'board', 'reserved_matters', 'dividends', 'exit', 'deadlock']); $bt['franchise_terms'] = $this->object(['brand' => $s, 'territory' => $s, 'term' => $s, 'initial_fee' => $n, 'royalties' => $s, 'support' => $s, 'exclusivity' => $s, 'renewal' => $s, 'termination' => $s, 'brand_license_document_id' => $d], ['brand', 'territory', 'term', 'initial_fee', 'royalties', 'support', 'exclusivity', 'renewal', 'termination', 'brand_license_document_id']); $bt += ['available_financial_years' => ['type' => 'integer', 'minimum' => 1], 'nda_document_id' => $d, 'teaser' => $s]; $defs['businessTransactionCreate']['required'] = [...$defs['businessTransactionCreate']['required'], 'entity_registration', 'available_financial_years']; return $schema; } /** @param array $p * @param array $resources * @param array $errors */ public function validate(array $p, Opportunity $record, array $resources, array &$errors): void { $fail = function (bool $ok, string $path, string $message) use (&$errors) { if (! $ok) { $errors['canonical.'.$path] = $message; } }; // Every nested reference belongs to this record, not merely to an existing document. $walk = function (array $node, string $path = '') use (&$walk, $resources, $fail) { foreach ($node as $key => $value) { $here = ltrim($path.'.'.$key, '.'); if (is_array($value)) { $walk($value, $here); } elseif (is_string($key) && (str_ends_with($key, 'document_id') || in_array($key, ['image_id', 'location_id'], true)) && $value !== null && $value !== '') { $kind = $key === 'image_id' ? 'images' : ($key === 'location_id' ? 'locations' : 'documents'); $fail(isset($resources[$kind][$value]), $here, 'Selecione um recurso ativo desta oportunidade, incluído no contrato.'); } if ($key === 'authority_id' || $key === 'issuing_authority_id') { $fail($value === null || DB::table('authorities')->where('id', $value)->where('status', 'published')->where('valid_from', '<=', today())->where(fn ($q) => $q->whereNull('valid_to')->orWhere('valid_to', '>=', today()))->exists(), $here, 'Selecione uma entidade emissora publicada e aplicável nesta data.'); } } }; $walk($p); if (isset($p['real_estate']) || isset($p['business_transaction'])) { $fail(isset($p['currency']) && is_string($p['currency']) && preg_match('/^[A-Z]{3}$/D', $p['currency']) === 1, 'currency', 'Indique a moeda de todos os valores desta versão.'); } if ($p['opportunity_category'] === 'investment_project') { $milestones = $p['milestones']; $fail(count($milestones) >= 2, 'milestones', 'Indique pelo menos dois marcos.'); $titles = array_column($milestones, 'title'); $fail(count($titles) === count(array_unique($titles)), 'milestones', 'Os nomes dos marcos devem identificar cada dependência sem ambiguidade.'); $edges = []; foreach ($milestones as $i => $m) { $fail($m['end_date'] >= $m['start_date'], "milestones.$i.end_date", 'O fim não pode anteceder o início.'); $edges[$m['title']] = $m['dependencies'] ?? []; $fail(array_diff($edges[$m['title']], $titles) === [], "milestones.$i.dependencies", 'Uma dependência deve indicar outro marco deste cronograma.'); } $seen = []; $active = []; $visit = function ($title) use (&$visit, &$seen, &$active, $edges): bool { if (isset($active[$title])) { return false; }if (isset($seen[$title])) { return true; }$active[$title] = true; foreach ($edges[$title] ?? [] as $parent) { if (! $visit($parent)) { return false; } }unset($active[$title]); $seen[$title] = true; return true; }; foreach ($titles as $title) { $fail($visit($title), 'milestones', 'As dependências não podem formar um ciclo.'); } foreach ($p['risks'] as $i => $risk) { $fail(($risk['likelihood'] < 4 && $risk['severity'] < 4) || ! empty($risk['mitigation']), "risks.$i.mitigation", 'Risco com probabilidade ou gravidade alta exige mitigação.'); } if (app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment') && in_array($p['maturity_stage'], ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational'], true)) { $fail(! empty($p['financial_indicators']), 'financial_indicators', 'A partir de viabilidade, indique cenário, indicadores e modelo financeiro.'); $fail(count(array_filter($p['evidences'], fn ($e) => in_array($e['evidence_type'], ['feasibility_study', 'feasibility'], true))) > 0, 'evidences', 'Associe o estudo de viabilidade desta etapa.'); } } if (isset($p['real_estate'])) { $re = $p['real_estate']; $types = array_column($re['utilities'], 'type'); $fail(count($types) === 5 && count(array_unique($types)) === 5, 'real_estate.utilities', 'Declare os cinco serviços: eletricidade, água, saneamento, telecomunicações e acesso.'); if ($re['occupancy_status'] !== 'vacant') { foreach (['occupancy_details', 'occupancy_document_id'] as $field) { $fail(! empty($re[$field]), 'real_estate.'.$field, 'Ocupação exige detalhe contratual restrito e documento.'); } } if (in_array($re['construction_status'], ['planned', 'under_construction'], true)) { $fail(! empty($re['expected_completion_date']) && $re['expected_completion_date'] >= today()->toDateString(), 'real_estate.expected_completion_date', 'Indique prazo de conclusão futuro ou atual.'); } if (in_array('other', $re['amenities'] ?? [], true)) { $fail(! empty($re['amenities_other']), 'real_estate.amenities_other', 'Descreva a comodidade indicada como outra.'); } if (isset($re['max_built_area'])) { $fail($re['built_area'] <= $re['max_built_area'], 'real_estate.built_area', 'A área excede o limite urbanístico declarado.'); $fail(! empty($re['urban_limit_document_id']), 'real_estate.urban_limit_document_id', 'Associe a fonte do limite urbanístico.'); } foreach ($re['encumbrances'] as $i => $row) { if ($row['type'] !== 'none') { foreach (['details', 'document_id'] as $field) { $fail(! empty($row[$field]), "real_estate.encumbrances.$i.$field", 'Ónus ou restrição exige descrição e documento.'); } } } } if (isset($p['land_rights'])) { $lr = $p['land_rights']; // §27: UNIQUE (opportunity_id, cadastral_number); absent numbers remain unknown. $cadastralNumbers = []; foreach ($lr['parcels'] ?? [] as $i => $parcel) { $number = $parcel['cadastral_number'] ?? null; if ($number !== null && $number !== '') { $fail(! in_array($number, $cadastralNumbers, true), "land_rights.parcels.$i.cadastral_number", 'Número cadastral repetido nesta oportunidade; recupere e reconcilie a parcela existente.'); $cadastralNumbers[] = $number; } } if (in_array($lr['duat_status'], ['provisional', 'definitive'], true)) { $fail(! empty($lr['issuing_authority_id']), 'land_rights.issuing_authority_id', 'DUAT provisório/definitivo exige entidade emissora.'); if (! empty($lr['duat_number']) && ! empty($lr['issuing_authority_id'])) { $fail(! Opportunity::whereKeyNot($record->id)->whereNotNull('owner_user_id')->whereRaw("management_draft->'canonical'->'land_rights'->>'duat_number'=?", [$lr['duat_number']])->whereRaw("management_draft->'canonical'->'land_rights'->>'issuing_authority_id'=?", [$lr['issuing_authority_id']])->whereNotIn('status', ['archived', 'rejected'])->exists(), 'land_rights.duat_number', 'Este número já está associado à mesma entidade emissora; reconciliar antes de submeter.'); } } if (in_array($lr['operation_type'], ['transfer_of_improvements', 'lease_of_improvements'], true)) { $fail(! empty($lr['improvements']), 'land_rights.improvements', 'A operação sobre benfeitorias exige a respetiva descrição/evidência.'); } foreach ($lr['improvements'] ?? [] as $i => $row) { foreach (['assessed_value', 'currency', 'valuation_date', 'document_id', 'image_id'] as $field) { $fail(isset($row[$field]), "land_rights.improvements.$i.$field", 'Benfeitoria exige valor, data, moeda, documento e fotografia.'); } } $fail(! $lr['exploitation_plan_required'] || ! empty($lr['exploitation_plan_document_id']), 'land_rights.exploitation_plan_document_id', 'Plano de exploração aplicável exige documento.'); $fail(! $lr['communities_affected'] || (! empty($lr['communities']) && ! empty($lr['community_consultation'])), 'land_rights.community_consultation', 'Comunidade afetada exige comunidade, representantes e consulta documentada.'); $communityKeys = []; foreach ($lr['communities'] ?? [] as $i => $row) { $key = json_encode([$row['name'] ?? null, $row['location_id'] ?? null]); $fail(! isset($communityKeys[$key]), "land_rights.communities.$i.name", 'A mesma comunidade/localização já está incluída (§27).'); $communityKeys[$key] = true; foreach (['representative_name', 'consultation_date', 'location_id'] as $field) { $fail(! empty($row[$field]), "land_rights.communities.$i.$field", 'Identifique representante, data e localização da comunidade.'); } } } if (isset($p['business_transaction'])) { $bt = $p['business_transaction']; $type = $bt['transaction_type']; $allowed = match ($p['opportunity_category']) { 'business_sale' => ['full_sale', 'partial_sale', 'succession'],'business_partnership' => ['partnership', 'joint_venture'],'franchise' => ['franchise'],default => [] }; $fail(in_array($type, $allowed, true), 'business_transaction.transaction_type', 'Modalidade incompatível com esta categoria.'); $years = array_column($bt['financial_periods'], 'year'); if (($bt['history_status'] ?? '') === 'no_completed_years') { $fail(app(OpportunityModularProfiles::class)->enabled() && ($p['module_profile_version'] ?? '') === OpportunityModularProfiles::HISTORY_VERSION, 'module_profile_version', 'Ausência de histórico requer a extensão QA v2 explícita; preserve o contrato original.'); $fail($bt['available_financial_years'] === 0 && $bt['financial_periods'] === [], 'business_transaction.financial_periods', 'Não misture projeções com exercícios financeiros realizados.'); foreach (['history_declaration', 'history_declaration_document_id', 'projections'] as $field) { $fail(! empty($bt[$field]), 'business_transaction.'.$field, 'Entidade sem exercícios completos requer declaração e projeções identificadas.'); } } else { $fail($bt['available_financial_years'] >= 1 && $bt['financial_periods'] !== [], 'business_transaction.financial_periods', 'Use os exercícios existentes ou a declaração versionada de ausência de histórico.'); } $fail(count($years) >= min(3, $bt['available_financial_years']) && count($years) === count(array_unique($years)), 'business_transaction.financial_periods', 'Inclua pelo menos três exercícios quando existentes, sem repetir anos.'); foreach ($years as $i => $year) { $fail($year <= (int) today()->format('Y'), "business_transaction.financial_periods.$i.year", 'Não apresente um exercício futuro como demonstração realizada.'); } $fail($bt['litigation_contingencies'] !== [], 'business_transaction.litigation_contingencies', 'Declare contingências, incluindo explicitamente a ausência.'); foreach ($bt['litigation_contingencies'] as $i => $row) { if ($row['type'] !== 'none') { foreach (['value', 'status', 'probability', 'entity', 'document_id'] as $field) { $fail(isset($row[$field]), "business_transaction.litigation_contingencies.$i.$field", 'Contingência exige valor, estado, probabilidade, responsável/evidência restrita.'); } } } if ($bt['confidentiality_level'] === 'nda_required') { $fail(! empty($bt['nda_document_id']), 'business_transaction.nda_document_id', 'Associe o acordo de confidencialidade aplicável.'); } if ($p['visibility'] === 'public') { $fail(! empty($bt['teaser']), 'business_transaction.teaser', 'Prepare o texto público sem informação confidencial.'); } } foreach (['real_estate', 'business_transaction'] as $module) { if (isset($p[$module])) { $v = $p[$module]['valuation']; $fail($v['value'] > 0, "$module.valuation.value", 'Avaliação deve ter valor positivo.'); $fail($v['date'] <= today()->toDateString(), "$module.valuation.date", 'A data da avaliação não pode ser futura.'); // No arbitrary materiality threshold: reviewer determines divergence and its evidence. } } } /** @param array $p * @param array $errors */ public function publication(array $p, array &$errors): void { if (isset($p['business_transaction'])) { foreach ($p['business_transaction']['contracts_licenses'] as $i => $row) { if ($row['expiry_date'] < today()->toDateString() || $row['transferability'] === 'not_transferable' || ($row['transferability'] === 'consent_required' && empty($row['consent_document_id']))) { $errors["business_transaction.contracts_licenses.$i"] = 'Contrato/licença vencido, intransmissível ou sem consentimento exigido.'; } } } if (isset($p['land_rights'])) { $lr = $p['land_rights']; if (! empty($lr['expiry_date']) && $lr['expiry_date'] < today()->toDateString()) { $errors['land_rights.expiry_date'] = 'Direito expirado; decisão interna não renova o ato externo.'; } if ($lr['communities_affected'] && ! in_array($lr['community_consultation']['status'] ?? '', ['completed', 'approved'], true)) { $errors['land_rights.community_consultation'] = 'Consulta necessária ainda não concluída/documentada.'; } } } }