Files
Investidor-V2/app/Services/OpportunityCategoryFields.php
T
nala 01405cbf8a
tests / ci (push) Canceled after 0s
oportunidades 5
2026-10-10 14:15:28 +02:00

278 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
/** §25 and common fields 415–422: explicit elaboration of opaque objects, shared by API and editor. */
class OpportunityCategoryFields
{
public const VERSION = 'semantic-fields-20261010-1';
/** @return array<string,mixed> */
private function text(): array
{
return ['type' => 'string', 'minLength' => 1, 'maxLength' => 1000];
}
/** @return array<string,mixed> */
private function doc(): array
{
return ['type' => 'string', 'format' => 'uuid'];
}
/** @return array<string,mixed> */
private function money(): array
{
return ['type' => 'number', 'minimum' => 0];
}
/** @param array<string,mixed> $properties
* @param list<string> $required
* @return array<string,mixed> */
private function object(array $properties, array $required = []): array
{
return ['type' => 'object', 'properties' => $properties, 'required' => $required, 'additionalProperties' => true];
}
/** @param array<string,mixed> $properties
* @param list<string> $required
* @return array<string,mixed> */
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<string,mixed> $schema
* @return array<string,mixed> */
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];
}
$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], ['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];
$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<string,mixed> $p
* @param array<string,mixed> $resources
* @param array<string,string> $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 (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'];
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.');
foreach ($lr['communities'] ?? [] as $i => $row) {
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'],'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');
$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<string,mixed> $p
* @param array<string,string> $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.';
}
}
}
}