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

203 lines
12 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Additional factual fields in the existing versioned funding catalogue. */
final class FundingInstitutionDetails
{
public const NATURES = ['bank' => 'Banco', 'credit' => 'Instituição de crédito', 'microfinance' => 'Microfinanças', 'investment_fund' => 'Fundo de investimento', 'development' => 'Agência de desenvolvimento/financiamento', 'other' => 'Outra natureza documentada', 'unknown' => 'Natureza por confirmar'];
public const NEEDS = ['investment' => 'Investimento/equipamento', 'working_capital' => 'Fundo de maneio', 'equity' => 'Participação de capital', 'guarantee' => 'Garantia', 'technical_assistance' => 'Assistência técnica', 'insurance' => 'Seguros', 'foreign_exchange' => 'Câmbio', 'payments' => 'Pagamentos', 'pension' => 'Pensões', 'investment_management' => 'Gestão de investimento', 'other' => 'Outra necessidade documentada'];
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function normalize(array $input, bool $publishing): array
{
$required = $publishing ? 'required' : 'nullable';
$rules = [
'institution_key' => 'required|string|regex:/^[a-z0-9][a-z0-9.-]{0,99}$/D',
'institution_nature' => ['required', Rule::in(array_keys(self::NATURES))],
'contacts' => 'array|max:100', 'locations' => 'array|max:100', 'products' => 'array|max:40',
'contacts.*' => 'array:id,kind,value,source_url,source_reference,checked_on,source_document_name,source_document_sha256,evidence_status,origin,source_refs',
'contacts.*.kind' => 'required|in:website,email,phone,address', 'contacts.*.value' => "$required|string|max:1000",
'locations.*' => 'array:id,role,label,address,territory_id,source_url,source_reference,checked_on,source_document_name,source_document_sha256,evidence_status,origin,source_refs,location_text,hours',
'locations.*.role' => 'required|in:headquarters,branch,contact_point', 'locations.*.label' => "$required|string|max:250",
'locations.*.address' => 'nullable|string|max:1500', 'locations.*.territory_id' => 'nullable|uuid',
'products.*' => 'array:code,title,instrument,needs,coverage,coverage_text,territory_ids,conditions,amounts,availability,contact_url,source_url,source_reference,checked_on,source_document_name,source_document_sha256',
'products.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'products.*.title' => "$required|string|max:250",
'products.*.instrument' => 'nullable|string|max:250', 'products.*.needs' => 'array|max:6',
'products.*.needs.*' => ['required', 'distinct', Rule::in(array_keys(self::NEEDS))],
'products.*.coverage' => 'required|in:national,territorial,unknown', 'products.*.coverage_text' => 'nullable|string|max:2000',
'products.*.territory_ids' => 'array|max:30', 'products.*.territory_ids.*' => 'required|uuid|distinct',
'products.*.conditions' => 'nullable|string|max:4000', 'products.*.amounts' => 'nullable|string|max:2000',
'products.*.availability' => 'required|in:documented,unknown,closed', 'products.*.contact_url' => 'nullable|url:http,https|max:2000',
];
foreach (['contacts', 'locations', 'products'] as $prefix) {
$rules[$prefix.'.*.source_url'] = 'nullable|url:http,https|max:2000';
$rules[$prefix.'.*.source_document_name'] = 'nullable|string|max:250';
$rules[$prefix.'.*.source_document_sha256'] = 'nullable|string|regex:/^[a-f0-9]{64}$/D';
$rules[$prefix.'.*.source_reference'] = "$required|string|max:1500";
$rules[$prefix.'.*.checked_on'] = "$required|date_format:Y-m-d|before_or_equal:today";
}
foreach (['contacts', 'locations'] as $prefix) {
$rules[$prefix.'.*.id'] = 'nullable|string|max:100';
$rules[$prefix.'.*.evidence_status'] = 'nullable|in:reported,to_confirm,historical,conflict,unlocated';
$rules[$prefix.'.*.origin'] = 'nullable|string|max:500';
$rules[$prefix.'.*.source_refs'] = 'array|max:100';
$rules[$prefix.'.*.source_refs.*'] = 'required|string|max:100';
}
$rules['locations.*.location_text'] = 'nullable|string|max:1000';
$rules['locations.*.hours'] = 'nullable|string|max:1000';
$data = [...Validator::make($input, $rules)->validate(), ...app(FundingInstitutionClassification::class)->normalize($input)];
if ($publishing) {
foreach (['contacts', 'locations', 'products'] as $prefix) {
foreach ($data[$prefix] ?? [] as $item) {
Validator::make($item, ['source_url' => empty($item['source_document_sha256']) ? 'required|url:http,https' : 'nullable|url:http,https', 'source_document_name' => empty($item['source_url']) ? 'required|string' : 'nullable|string'])->validate();
}
}
}
foreach (['contacts', 'locations', 'products'] as $key) {
$data[$key] ??= [];
}
foreach ($data['contacts'] as $contact) {
Validator::make(['value' => $contact['value'] ?? null], ['value' => match ($contact['kind']) {
'website' => "$required|url:http,https|max:1000",
'email' => "$required|email|max:250",
default => "$required|string|max:1000",
}])->validate();
}
foreach ($data['locations'] as &$location) {
if (! empty($location['territory_id'])) {
$this->territory($location['territory_id']);
if ($publishing) {
$location['territory_path'] = $this->path($location['territory_id']);
}
}
}
unset($location);
foreach ($data['products'] as &$product) {
$product['needs'] ??= [];
$product['territory_ids'] ??= [];
Validator::make($product, ['territory_ids' => $product['coverage'] === 'territorial' ? ($publishing ? 'required|array|min:1' : 'array') : 'array|size:0'])->validate();
// Territorial drafts may be incomplete; other coverage modes never inherit the headquarters.
foreach ($product['territory_ids'] as $id) {
$this->territory($id);
}
if ($publishing) {
$product['territory_paths'] = array_map($this->path(...), $product['territory_ids']);
}
}
unset($product);
return $data;
}
private function territory(string $id): void
{
Validator::make(['territory' => $id], ['territory' => [Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())]])->validate();
}
/** @return list<array{id:string,name:string,type:string}> */
private function path(string $id): array
{
return array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id));
}
/** Same product must satisfy both location and need; branch addresses never imply lending coverage.
* @param array<string,mixed> $product
* @param array<string,mixed> $filters */
public function match(array $product, array $filters): bool
{
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
if ($product['availability'] === 'closed') {
return false;
}
if (! $unknown && $product['availability'] === 'unknown') {
return false;
}
if (! empty($filters['need']) && ! in_array($filters['need'], $product['needs'], true) && ! ($unknown && $product['needs'] === [])) {
return false;
}
if (empty($filters['territory'])) {
return true;
}
if ($product['coverage'] === 'national') {
return true;
}
if ($product['coverage'] === 'unknown') {
return $unknown;
}
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
foreach ($product['territory_paths'] ?? [] as $covered) {
$ids = array_column($covered, 'id');
if (array_intersect($path, $product['territory_ids']) !== [] || in_array($filters['territory'], $ids, true)) {
return true;
}
}
return false;
}
/** @param array<string,mixed> $filters */
public function applyFilters(Builder $query, array $filters): void
{
app(FundingInstitutionClassification::class)->filter($query, $filters);
if (empty($filters['need']) && (empty($filters['territory']) || ($filters['location_mode'] ?? '') === 'physical')) {
return;
}
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
$conditions = ["p->>'availability' <> 'closed'"];
$bindings = [];
if (! $unknown) {
$conditions[] = "p->>'availability' <> 'unknown'";
}
if (! empty($filters['need'])) {
$conditions[] = "(p->'needs' @> ?::jsonb".($unknown ? " OR jsonb_array_length(p->'needs')=0" : '').')';
$bindings[] = json_encode([$filters['need']], JSON_THROW_ON_ERROR);
}
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$parts = ["p->>'coverage'='national'", "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p->'territory_paths','[]'::jsonb)) t WHERE t @> ?::jsonb)"];
$bindings[] = json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR);
foreach ($path as $id) {
$parts[] = "p->'territory_ids' @> ?::jsonb";
$bindings[] = json_encode([$id], JSON_THROW_ON_ERROR);
}
if ($unknown) {
$parts[] = "p->>'coverage'='unknown'";
}
$conditions[] = '('.implode(' OR ', $parts).')';
}
$sql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) p WHERE ".implode(' AND ', $conditions).')';
$query->where(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->where(function (Builder $q) use ($unknown, $filters): void {
$q->whereRaw("v.payload->>'kind' <> 'institution'");
if (! empty($filters['need'])) {
$q->where(function (Builder $q) use ($filters, $unknown): void {
$q->whereRaw("v.payload->'funding_needs' @> ?::jsonb", [json_encode([$filters['need']], JSON_THROW_ON_ERROR)]);
if ($unknown) {
$q->orWhereRaw("jsonb_array_length(COALESCE(v.payload->'funding_needs','[]'::jsonb))=0");
}
});
}
})->orWhere(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->whereRaw("v.payload->>'kind' = 'institution'")->where(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->whereRaw($sql, $bindings);
if ($unknown) {
$q->orWhere(function (Builder $p) use ($filters): void {
$p->whereRaw("jsonb_array_length(COALESCE(v.payload->'products','[]'::jsonb))=0");
if (! empty($filters['need'])) {
$p->where(fn ($z) => $z->whereRaw("v.payload->'institution_services' @> ?::jsonb", [json_encode([$filters['need']], JSON_THROW_ON_ERROR)])->orWhereRaw("NOT jsonb_exists(v.payload, 'institution_services')"));
}
});
}
});
});
});
}
}