Files
Investidor-V2/app/Services/OpportunityCategoryFields.php
T

312 lines
24 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];
}
// 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<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 (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<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.';
}
}
}
}