Files
Investidor-V2/app/Services/OpportunityModularProfiles.php
nala 20971d523f
tests / ci (push) Canceled after 0s
oportunidades 6
2026-10-10 21:17:46 +02:00

248 lines
15 KiB
PHP
Raw Permalink 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;
/** Explicit QA-only extension of the accepted contract; existing draft/resources are the storage. */
class OpportunityModularProfiles
{
public const VERSION = 'modular-qa-20261010-v1';
public const HISTORY_VERSION = 'business-history-qa-20261010-v2';
public const PROFILES = [
'concession' => 'concession-qa-20261010-v1',
'financing' => 'financing-qa-20261010-v1',
'supply' => 'supply-qa-20261010-v1',
'other' => 'other-qa-20261010-v1',
];
public function enabled(): bool
{
if (! config('portal.opportunity_modular_profiles_qa_enabled') || ! app()->environment('testing')) {
return false;
}
$connection = DB::connection();
if ($connection->getDriverName() !== 'pgsql' || (string) $connection->getConfig('port') !== '55441'
|| ! in_array($connection->getConfig('host'), ['127.0.0.1', 'localhost'], true)
|| $connection->getConfig('url') || ! str_ends_with($connection->getDatabaseName(), '_test')) {
return false;
}
$actual = $connection->selectOne('SELECT current_database() AS database, inet_server_port() AS port');
return $actual->database === $connection->getDatabaseName() && (int) $actual->port === 55441;
}
/** @param array<string,mixed> $schema
* @return array<string,mixed> */
public function extend(array $schema): array
{
if (! $this->enabled()) {
return $schema;
}
$text = ['type' => 'string', 'minLength' => 1, 'maxLength' => 2000];
$doc = ['type' => 'string', 'format' => 'uuid'];
$positive = ['type' => 'number', 'exclusiveMinimum' => 0];
$date = ['type' => 'string', 'format' => 'date'];
$properties = [
'mode' => ['enum' => ['intention_call', 'awarded_right', 'seeking_funding', 'funding_offer', 'purchase_request', 'supply_offer', 'classified_other']],
'conditions_status' => ['enum' => ['proposed', 'binding']],
'object' => $text, 'representation_document_id' => $doc, 'object_document_id' => $doc,
'terms' => $text, 'source' => $text, 'scenario' => $text, 'terms_date' => $date,
'commitment_document_id' => $doc, 'disclosure_authorization_document_id' => $doc,
'granting_entity' => $text, 'procedure_regime' => $text, 'scope' => $text,
'term_value' => $positive, 'term_unit' => ['enum' => ['days', 'months', 'years']],
'remuneration_obligations' => $text, 'participation_conditions' => $text,
'external_decision_document_id' => $doc, 'territorial_right_document_id' => $doc,
'land_required' => ['type' => 'boolean'], 'construction_required' => ['type' => 'boolean'],
'applicable_authorizations' => $text, 'construction_license_document_id' => $doc,
'start_date' => $date, 'end_date' => $date,
'instrument' => ['enum' => ['debt', 'equity', 'guarantee']],
'use_of_funds' => $text, 'disbursement_calendar' => $text, 'repayment_periodicity' => $text,
'financial_plan_document_id' => $doc, 'secured_evidence_document_id' => $doc,
'offered_capacity' => $positive, 'eligibility_criteria' => $text, 'capacity_document_id' => $doc,
'equity_percentage' => ['type' => 'number', 'exclusiveMinimum' => 0, 'maximum' => 100],
'proposed_rights' => $text, 'quantity' => $positive, 'unit' => $text, 'destination' => $text,
'acceptance_criteria' => $text, 'quotation_method' => $text, 'unit_price' => $positive, 'total_price' => $positive,
'declared_taxes' => $text, 'advance_or_guarantee' => ['type' => 'boolean'],
'subtype' => $text, 'purpose' => $text, 'recipients' => $text, 'expected_output' => $text, 'risks' => $text,
];
$schema['$defs']['opportunityCreate']['properties']['module_details'] = [
'type' => 'object', 'properties' => $properties, 'additionalProperties' => false,
];
// Opt-in v2: a declaration/projection is not a fabricated completed financial year.
$business = &$schema['$defs']['businessTransactionCreate'];
$business['properties']['available_financial_years']['minimum'] = 0;
$business['properties']['financial_periods']['minItems'] = 0;
$business['properties'] += ['history_status' => ['enum' => ['existing_accounts', 'no_completed_years']],
'history_declaration' => $text, 'history_declaration_document_id' => $doc,
'projections' => ['type' => 'object', 'properties' => ['scenario' => $text, 'source' => $text, 'date' => $date, 'document_id' => $doc],
'required' => ['scenario', 'source', 'date', 'document_id'], 'additionalProperties' => false]];
$business['allOf'][] = ['if' => ['properties' => ['history_status' => ['const' => 'no_completed_years']], 'required' => ['history_status']],
'else' => ['properties' => ['financial_periods' => ['minItems' => 1], 'available_financial_years' => ['minimum' => 1]]]];
return $schema;
}
/** @param array<string,mixed> $p
* @param array<string,mixed> $resources
* @param array<string,string> $errors */
public function validate(array $p, array $resources, array &$errors): void
{
$category = $p['opportunity_category'] ?? '';
if (! isset(self::PROFILES[$category])) {
if (isset($p['module_details'])) {
$errors['canonical.module_details'] = 'Detalhe modular incompatível com esta categoria.';
}
return;
}
if (! $this->enabled() || ($p['module_profile_version'] ?? '') !== self::PROFILES[$category]) {
$errors['canonical.module_profile_version'] = 'Use a versão QA explicitamente aprovada; versões anteriores permanecem rascunhos recuperáveis.';
return;
}
$m = $p['module_details'] ?? [];
$fail = function (bool $ok, string $path, string $message) use (&$errors): void {
if (! $ok) {
$errors['canonical.'.$path] = $message;
}
};
$require = function (array $fields) use ($m, $fail): void {
foreach ($fields as $field) {
$fail(isset($m[$field]) && $m[$field] !== '', 'module_details.'.$field, 'Preencha este campo aplicável antes de submeter.');
}
};
$require(['mode', 'object', 'representation_document_id', 'object_document_id']);
$modes = match ($category) {
'concession' => ['intention_call', 'awarded_right'], 'financing' => ['seeking_funding', 'funding_offer'],
'supply' => ['purchase_request', 'supply_offer'], default => ['classified_other'],
};
$fail(in_array($m['mode'] ?? '', $modes, true), 'module_details.mode', 'Selecione procura/oferta ou modalidade compatível com esta categoria.');
// Only references included in the current opportunity's verified resource snapshot are usable.
foreach ($m as $key => $value) {
if (str_ends_with($key, '_document_id') && $value !== null && $value !== '') {
$fail(isset($resources['documents'][$value]), 'module_details.'.$key, 'Associe documento ativo desta oportunidade incluído no contrato.');
}
}
if (! empty($m['start_date']) && ! empty($m['end_date'])) {
$fail($m['end_date'] >= $m['start_date'], 'module_details.end_date', 'O fim não pode anteceder o início.');
}
if ($category === 'concession') {
$require(['granting_entity', 'procedure_regime', 'scope', 'term_value', 'term_unit', 'remuneration_obligations', 'participation_conditions', 'land_required', 'construction_required']);
$fail(($m['term_value'] ?? 0) > 0, 'module_details.term_value', 'Declare duração positiva.');
if (($m['mode'] ?? '') === 'awarded_right') {
$require(['external_decision_document_id']);
}
if ($m['land_required'] ?? false) {
$require(['applicable_authorizations']);
if (($m['mode'] ?? '') === 'awarded_right') {
$require(['territorial_right_document_id']);
}
}
if ($m['construction_required'] ?? false) {
$require(['applicable_authorizations']);
}
}
if (in_array($category, ['financing', 'supply'], true)) {
$require(['conditions_status', 'terms']);
$fail(! empty($p['currency']), 'currency', 'Declare moeda sem converter valores implicitamente.');
if (($m['conditions_status'] ?? '') === 'proposed') {
$require(['scenario', 'source', 'terms_date']);
$fail(empty($m['terms_date']) || $m['terms_date'] <= today()->toDateString(), 'module_details.terms_date', 'A data da fonte não pode ser futura.');
} elseif (($m['conditions_status'] ?? '') === 'binding') {
$require(['commitment_document_id']);
}
}
if ($category === 'financing') {
$require(['instrument', 'disbursement_calendar']);
if (($m['mode'] ?? '') === 'seeking_funding') {
$require(['use_of_funds', 'financial_plan_document_id']);
foreach (['total_investment', 'investment_breakdown', 'secured_financing', 'funding_sought'] as $field) {
$fail(array_key_exists($field, $p), $field, 'Complete o cenário do projeto para a procura de financiamento.');
}
$total = $p['total_investment'] ?? 0;
$secured = array_sum(array_column($p['secured_financing'] ?? [], 'amount'));
$sum = array_sum(array_column($p['investment_breakdown'] ?? [], 'amount'));
$fail($total > 0 && abs($sum - $total) <= 0.010001, 'investment_breakdown', 'Rubricas devem reconciliar com o investimento total positivo.');
$fail($secured <= $total, 'secured_financing', 'Financiamento assegurado não pode exceder o investimento.');
$sought = $p['funding_sought'] ?? 0;
$fail($sought > 0 && $sought <= $total - $secured, 'funding_sought', 'Pedido positivo não pode exceder a necessidade remanescente do projeto.');
if ($secured > 0) {
$require(['secured_evidence_document_id']);
}
foreach (['investment_breakdown', 'secured_financing'] as $field) {
foreach ($p[$field] ?? [] as $i => $row) {
$fail(($row['currency'] ?? '') === ($p['currency'] ?? ''), "$field.$i.currency", 'Moedas diferentes exigem cenário explícito; não somar automaticamente.');
}
}
} elseif (($m['mode'] ?? '') === 'funding_offer') {
$require(['offered_capacity', 'eligibility_criteria', 'capacity_document_id']);
$fail(($m['offered_capacity'] ?? 0) > 0, 'module_details.offered_capacity', 'Declare capacidade oferecida positiva e documentada, sem aprovação automática de beneficiários.');
foreach (['total_investment', 'investment_breakdown', 'secured_financing', 'funding_sought'] as $field) {
$fail(! isset($p[$field]) || $p[$field] === [], $field, 'Oferta usa capacidade e critérios do financiador; preserve o cenário do projeto num percurso de procura.');
}
}
if (($m['instrument'] ?? '') === 'debt') {
$require(['term_value', 'term_unit', 'repayment_periodicity']);
$fail(($m['term_value'] ?? 0) > 0, 'module_details.term_value', 'Dívida exige prazo positivo.');
} elseif (($m['instrument'] ?? '') === 'equity') {
$require(['equity_percentage', 'proposed_rights']);
$fail(($m['equity_percentage'] ?? 0) > 0 && ($m['equity_percentage'] ?? 0) <= 100, 'module_details.equity_percentage', 'Percentagem deve estar entre zero exclusivo e 100.');
}
}
if ($category === 'supply') {
$require(['quantity', 'unit', 'destination', 'start_date', 'end_date', 'acceptance_criteria', 'advance_or_guarantee']);
$fail(($m['quantity'] ?? 0) > 0, 'module_details.quantity', 'Declare quantidade positiva.');
$fail(! empty($m['total_price']) || ! empty($m['quotation_method']), 'module_details.quotation_method', 'Declare preço total ou método de cotação.');
if (isset($m['unit_price'], $m['total_price'], $m['quantity'])) {
$fail(abs($m['quantity'] * $m['unit_price'] - $m['total_price']) <= 0.010001, 'module_details.total_price', 'Reconcilie quantidade × preço unitário, separando impostos declarados.');
}
if (($m['mode'] ?? '') === 'supply_offer') {
$require(['capacity_document_id']);
}
}
if ($category === 'other') {
$require(['subtype', 'purpose', 'recipients', 'expected_output', 'risks']);
$subtype = config('portal.opportunity_other_qa_subtypes.'.($m['subtype'] ?? ''));
$fail(is_array($subtype) && ($subtype['approved'] ?? false) && ! empty($subtype['version'])
&& ! empty($subtype['gates']) && ! empty($subtype['decision_reference']), 'module_details.subtype', 'Subtipo ainda não aprovado: guarde e desenvolva a ideia; a submissão aguarda classificação funcional.');
}
}
/** @param array<string,mixed> $p
* @param array<string,string> $errors */
public function publication(array $p, array &$errors): void
{
$m = $p['module_details'] ?? [];
if (($p['opportunity_category'] ?? '') === 'concession') {
if (($m['mode'] ?? '') === 'intention_call' && empty($m['disclosure_authorization_document_id'])) {
$errors['module_details.disclosure_authorization_document_id'] = 'Chamada/intenção exige autorização de divulgação; não representa direito atribuído.';
}
if (($m['mode'] ?? '') === 'awarded_right' && empty($m['external_decision_document_id'])) {
$errors['module_details.external_decision_document_id'] = 'Direito atribuído exige o ato competente; aprovação interna não o substitui.';
}
if (($m['construction_required'] ?? false) && ($m['mode'] ?? '') === 'awarded_right' && empty($m['construction_license_document_id'])) {
$errors['module_details.construction_license_document_id'] = 'Construção aplicável exige licença antes da disponibilização como direito atribuído.';
}
}
}
/** @return list<string> */
public function gates(Opportunity $record): array
{
$p = app(OpportunityManagement::class)->values($record)['canonical'] ?? [];
$m = $p['module_details'] ?? [];
return match ($record->opportunity_category) {
'concession' => ['technical_review', 'legal_territorial_review'],
'financing' => ['technical_review', 'financial_review', 'legal_tax_due_diligence'],
'supply' => ['technical_review', 'legal_territorial_review', ...(($m['advance_or_guarantee'] ?? false) ? ['financial_review'] : [])],
'other' => config('portal.opportunity_other_qa_subtypes.'.($m['subtype'] ?? '').'.gates', []),
default => [],
};
}
}