248 lines
15 KiB
PHP
248 lines
15 KiB
PHP
<?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 => [],
|
||
};
|
||
}
|
||
}
|