209 lines
13 KiB
PHP
209 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\InvestmentPortfolioController;
|
|
use App\Models\Opportunity;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
/** Resolve documented complements without creating opportunities or master relations. */
|
|
final class InvestmentComplements
|
|
{
|
|
private const TERMS = [
|
|
'irrigation' => ['Regadio', 'regadio|irrigação|irrigacao'],
|
|
'storage' => ['Silos e armazenagem', 'silos?|armazenagem|armazenamento'],
|
|
'cold' => ['Cadeia de frio', 'frio|refrigeração'],
|
|
'processing' => ['Processamento', 'processamento|transformação|moagem'],
|
|
'spinning' => ['Fiação', 'fiação|fiacao'],
|
|
'logistics' => ['Logística', 'logística|logistica|transporte'],
|
|
'certification' => ['Certificação', 'certificação|certificacao'],
|
|
'training' => ['Formação', 'formação|formacao'],
|
|
'finance' => ['Financiamento', 'financiamento|serviços financeiros'],
|
|
];
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function options(string $origin, ?string $territory = null): array
|
|
{
|
|
[$originKind, $originId] = array_pad(explode(':', $origin, 2), 2, '');
|
|
abort_unless(($originKind === 'portfolio' && Str::isUuid($originId)) || ($originKind === 'opportunity' && ctype_digit($originId)), 422, 'Origem complementar inválida.');
|
|
$principal = null;
|
|
$relationEvidence = [];
|
|
if (str_starts_with($origin, 'portfolio:')) {
|
|
$record = InvestmentPortfolioController::visible()->addSelect('version_no')->whereKey(substr($origin, 10))->firstOrFail();
|
|
$text = trim(($record->content['focus'] ?? '')."\n".($record->content['gaps'] ?? '')."\n".($record->content['description'] ?? ''));
|
|
$reference = ['key' => $origin, 'title' => $record->title, 'href' => '/carteira/'.$record->id, 'version' => $record->version_no];
|
|
$ids = app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record);
|
|
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
|
|
if ($record->$foreign) {
|
|
$ids = [...$ids, ...DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->pluck('opportunity_id')->all()];
|
|
}
|
|
}
|
|
$basis = $record->source;
|
|
foreach (array_unique($ids) as $id) {
|
|
$relationEvidence[$id] = ['type' => 'portfolio', 'origin' => $origin, 'source' => $basis];
|
|
}
|
|
$serviceOrigins = [['dossier', $record->id]];
|
|
foreach (['project_id' => 'project', 'cluster_id' => 'cluster'] as $foreign => $type) {
|
|
if ($record->$foreign) {
|
|
$serviceOrigins[] = [$type, $record->$foreign];
|
|
}
|
|
}
|
|
} else {
|
|
$reference = app(InvestorContext::class)->reference($origin);
|
|
abort_unless($reference['kind'] === 'opportunity', 422);
|
|
// The context resolver has already checked the actor's private scope.
|
|
// A private preparation is valid dossier input, not a public offer
|
|
// from which this public-complement catalogue can infer services.
|
|
if (($reference['visibility'] ?? 'public') === 'private') {
|
|
return [];
|
|
}
|
|
$principal = Opportunity::published()->whereKey($reference['id'])->firstOrFail();
|
|
$text = $principal->summary;
|
|
$basis = 'Resumo cadastrado da oportunidade '.$principal->reference;
|
|
$projects = DB::table('opportunity_projects')->where('opportunity_id', $principal->id)->pluck('project_id');
|
|
$links = DB::table('complementary_project_links')->whereIn('anchor_project_id', $projects)
|
|
->whereNull('deleted_at')->where('data_classification', 'public')->get(['id', 'anchor_project_id', 'complementary_project_id', 'relation_type', 'version_no']);
|
|
$targetProjects = DB::table('opportunity_projects')->whereIn('project_id', $links->pluck('complementary_project_id'))->get();
|
|
$ids = [$principal->id, ...$targetProjects->pluck('opportunity_id')->all()];
|
|
$relationEvidence[$principal->id] = ['type' => 'same_fiche', 'source' => $basis];
|
|
foreach ($targetProjects as $targetProject) {
|
|
$relationEvidence[$targetProject->opportunity_id] = ['type' => 'complementary_project',
|
|
'links' => $links->where('complementary_project_id', $targetProject->project_id)->values()->map(fn ($link) => (array) $link)->all()];
|
|
}
|
|
foreach (Opportunity::published()->whereIn('id', $ids)->where('id', '!=', $principal->id)->get() as $linked) {
|
|
$text .= "\n".$linked->summary;
|
|
}
|
|
if (count($ids) > 1) {
|
|
$basis .= '; relações complementares públicas entre projectos';
|
|
}
|
|
$serviceOrigins = [['investment', (string) $principal->id]];
|
|
$classification = app(OpportunityClassification::class);
|
|
$principalChains = collect($classification->publicRows($principal))->where('target_type', 'chain')->filter(fn ($r) => ! empty($r['source_reference']));
|
|
$chains = $principalChains->pluck('target_id')->all();
|
|
if ($chains) {
|
|
Opportunity::published()->whereNot('id', $principal->id)->chunkById(100, function ($records) use (&$ids, &$relationEvidence, $chains, $principalChains, $classification) {
|
|
foreach ($records as $candidate) {
|
|
$related = collect($classification->publicRows($candidate))->where('target_type', 'chain')->filter(fn ($r) => ! empty($r['source_reference']));
|
|
$shared = array_intersect($chains, $related->pluck('target_id')->all());
|
|
if ($shared) {
|
|
$ids[] = $candidate->id;
|
|
$relationEvidence[$candidate->id] ??= ['type' => 'value_chain', 'principal' => $principalChains->whereIn('target_id', $shared)->values()->all(), 'target' => $related->whereIn('target_id', $shared)->values()->all()];
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
$territoryReference = $territory ? app(InvestorContext::class)->reference('territory:'.$territory) : null;
|
|
$candidates = Opportunity::published()->whereIn('id', array_unique($ids))->get();
|
|
$items = [];
|
|
foreach (self::TERMS as $code => [$label, $pattern]) {
|
|
if (! preg_match('/\b(?:'.$pattern.')\b/iu', $text, $match)) {
|
|
continue;
|
|
}
|
|
$offers = [];
|
|
foreach ($candidates as $candidate) {
|
|
// A common corridor alone does not prove the component. Keep the supporting excerpt.
|
|
if (! preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title."\n".$candidate->summary, $evidence)) {
|
|
continue;
|
|
}
|
|
// Mentioning a need for funding/training in a project's summary is not an offer.
|
|
if (in_array($code, ['certification', 'training', 'finance'], true) && ! preg_match('/\b(?:'.$pattern.')\b/iu', $candidate->title)) {
|
|
continue;
|
|
}
|
|
if (in_array($code, ['certification', 'training', 'finance'], true) && preg_match('/\b(?:necessidade|pedido|procura)\b/iu', $candidate->title)) {
|
|
continue;
|
|
}
|
|
if ($territory && $candidate->primary_territory_id && ! $this->compatible($territory, $candidate->primary_territory_id)) {
|
|
continue;
|
|
}
|
|
$offers[] = ['key' => 'opportunity:'.$candidate->id, 'title' => $candidate->title, 'href' => '/oportunidades/'.$candidate->slug,
|
|
'relationship' => $relationEvidence[$candidate->id] ?? null,
|
|
'version' => $candidate->management_version, 'evidence' => $evidence[0], 'location' => ($principal && $principal->id === $candidate->id ? 'Componente referido na própria ficha. ' : '').($candidate->primary_territory_id ? app(InvestorContext::class)->reference('territory:'.$candidate->primary_territory_id)['title'] : 'Implantação por definir')];
|
|
}
|
|
// Services are concrete published offerings explicitly associated with this source.
|
|
$services = [];
|
|
if (in_array($code, ['certification', 'training', 'finance'], true) && Schema::hasTable('partnership_origins') && Schema::hasTable('partnership_interests')) {
|
|
$services = DB::table('portal_partnerships as p')->join('partnership_origins as o', 'o.partnership_id', '=', 'p.id')
|
|
->where(function ($q) use ($serviceOrigins) {
|
|
foreach ($serviceOrigins as [$type, $id]) {
|
|
$q->orWhere(fn ($q) => $q->where('o.type', $type)->where('o.target_id', $id));
|
|
}
|
|
})->where('p.status', 'published')->where('p.kind', 'offering')
|
|
->where('p.partnership_type', $code === 'finance' ? 'finance' : 'training')
|
|
->when($code !== 'finance', fn ($q) => $q->where(fn ($q) => $q->where('p.title', 'ilike', '%'.$match[0].'%')->orWhereRaw("p.payload->>'summary' ILIKE ?", ['%'.$match[0].'%'])))->distinct()->get(['p.id', 'p.title'])
|
|
->filter(function ($p) use ($territory) {
|
|
if (! $territory) {
|
|
return true;
|
|
}
|
|
$locations = DB::table('partnership_locations')->where('partnership_id', $p->id)->pluck('geo_location_id');
|
|
|
|
return $locations->isEmpty() || $locations->contains(fn ($id) => $this->compatible($territory, $id));
|
|
})->map(fn ($p) => ['key' => 'partnership:'.$p->id, 'title' => $p->title, 'href' => '/parcerias/'.$p->id, 'location' => 'Consultar âmbito na oferta'])->values()->all();
|
|
}
|
|
$excerpt = collect(explode("\n", $text))->first(fn ($line) => preg_match('/\b(?:'.$pattern.')\b/iu', $line) === 1);
|
|
$items[] = ['code' => $code, 'title' => $label, 'origin' => $reference, 'territory_id' => $territory, 'territory' => $territoryReference,
|
|
'source' => $basis, 'excerpt' => $excerpt, 'source_sha256' => hash('sha256', $text), 'opportunities' => $offers, 'services' => $services];
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
private function compatible(string $a, string $b): bool
|
|
{
|
|
if ($a === $b) {
|
|
return true;
|
|
}
|
|
$geo = app(InvestmentGeography::class);
|
|
|
|
return in_array($b, $geo->descendants($a), true) || in_array($a, $geo->descendants($b), true);
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $input
|
|
* @param array<array-key, mixed> $previous
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function resolve(array $input, array $previous = []): array
|
|
{
|
|
$resolved = [];
|
|
foreach ($input as $choice) {
|
|
$target = $choice['target'] ?? null;
|
|
$old = collect($previous)->first(fn ($r) => $r['origin']['key'] === $choice['origin'] && $r['code'] === $choice['code'] && ($r['target']['key'] ?? null) === $target && ($r['territory_id'] ?? null) === ($choice['territory_id'] ?? null));
|
|
try {
|
|
$item = collect($this->options($choice['origin'], $choice['territory_id'] ?? null))->firstWhere('code', $choice['code']);
|
|
} catch (ModelNotFoundException $e) {
|
|
if (! $old) {
|
|
throw $e;
|
|
}
|
|
$item = null;
|
|
} catch (HttpException $e) {
|
|
if (! $old || $e->getStatusCode() !== 404) {
|
|
throw $e;
|
|
}
|
|
$item = null;
|
|
}
|
|
$offer = $target && $item ? collect([...$item['opportunities'], ...$item['services']])->firstWhere('key', $target) : null;
|
|
if ($old && (! $item || ($target && ! $offer) || (! $target && ($item['opportunities'] || $item['services'])))) {
|
|
$resolved[$old['key']] = [...$old, 'availability' => 'review_required'];
|
|
|
|
continue;
|
|
}
|
|
abort_unless($item, 422, 'Complemento sem referência documentada neste investimento.');
|
|
abort_if($target && ! $offer, 422, 'A oferta complementar deixou de estar disponível neste contexto.');
|
|
abort_if(! $target && ($item['opportunities'] || $item['services']), 422, 'Já existe uma oferta relacionada; seleccione a ficha correspondente.');
|
|
$key = hash('sha256', $choice['origin'].'|'.$choice['code'].'|'.($target ?? 'idea').'|'.($choice['territory_id'] ?? ''));
|
|
$resolved[$key] = ['key' => $key, 'origin' => $item['origin'], 'code' => $item['code'], 'title' => $item['title'],
|
|
'kind' => $target ? (str_starts_with($target, 'opportunity:') ? 'opportunity' : 'service') : 'need',
|
|
'target' => $offer, 'territory_id' => $item['territory_id'], 'territory' => $item['territory'], 'source' => $item['source'], 'excerpt' => $item['excerpt'], 'source_sha256' => $item['source_sha256']];
|
|
}
|
|
|
|
return array_values($resolved);
|
|
}
|
|
}
|