199 lines
11 KiB
PHP
199 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use stdClass;
|
|
|
|
class PartnershipCandidacyHistory
|
|
{
|
|
private const PAGE_SIZE = 10;
|
|
|
|
/** @var array<int,string|null> */
|
|
private array $people = [];
|
|
|
|
/** @var array<string,string|null> */
|
|
private array $organizations = [];
|
|
|
|
/** @return array<string,mixed> */
|
|
public function page(User $actor, int $id, ?string $before = null): array
|
|
{
|
|
$interest = DB::table('partnership_interests')->where('id', $id)->first();
|
|
abort_unless($interest !== null, 404);
|
|
$exchange = app(PartnershipExchange::class);
|
|
$record = $exchange->find($interest->partnership_id);
|
|
$responsible = $exchange->owns($actor, $record, false);
|
|
abort_unless($responsible || $exchange->owns($actor, $interest, false), 403, 'Histórico reservado aos intervenientes autorizados desta candidatura.');
|
|
$boundary = $before === null ? null : $this->decodeCursor($before, $actor, $id);
|
|
$events = DB::table('partnership_events')->where('partnership_id', $record->id)
|
|
->whereIn('event_type', ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'])
|
|
->whereRaw("payload->'payload'->>'interest_id' = ?", [(string) $id])
|
|
->when($boundary, fn ($query) => $query->where('aggregate_version', '<', $boundary))
|
|
->orderByDesc('aggregate_version')->limit(self::PAGE_SIZE + 1)->get(['aggregate_version', 'event_type', 'actor_id', 'occurred_at', 'payload']);
|
|
$hasMore = $events->count() > self::PAGE_SIZE;
|
|
$page = $events->take(self::PAGE_SIZE);
|
|
$entries = $page->map(fn ($event) => $this->entry($event, $actor, $interest, $responsible))->all();
|
|
$base = '/parcerias/candidaturas/'.$id.'/historico';
|
|
$last = $page->last();
|
|
$next = $hasMore && $last ? $base.'?before='.rawurlencode(Crypt::encryptString(json_encode(['actor' => $actor->id, 'interest' => $id, 'before' => (int) $last->aggregate_version], JSON_THROW_ON_ERROR))) : null;
|
|
|
|
return ['candidacy_id' => $id, 'entries' => $entries, 'next_url' => $next, 'first_url' => $base, 'older_page' => $before !== null,
|
|
'partnership_href' => $record->status === 'published' || $responsible ? '/parcerias/'.$record->id : null, 'dashboard_href' => '/parcerias/painel'];
|
|
}
|
|
|
|
private function decodeCursor(string $token, User $actor, int $id): int
|
|
{
|
|
try {
|
|
$cursor = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (DecryptException|\JsonException) {
|
|
throw ValidationException::withMessages(['before' => 'A referência da página não é válida. Volte ao início do histórico.']);
|
|
}
|
|
if (! is_array($cursor) || ($cursor['actor'] ?? null) !== $actor->id || ($cursor['interest'] ?? null) !== $id || ! is_int($cursor['before'] ?? null) || $cursor['before'] < 1) {
|
|
throw ValidationException::withMessages(['before' => 'A referência da página não pertence a esta candidatura e conta. Volte ao início do histórico.']);
|
|
}
|
|
|
|
return $cursor['before'];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function entry(stdClass $event, User $actor, stdClass $interest, bool $responsible): array
|
|
{
|
|
$envelope = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR);
|
|
$data = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : [];
|
|
$previous = is_array($data['previous'] ?? null) ? $data['previous'] : null;
|
|
$submitted = is_array($data['submitted'] ?? null) ? $data['submitted'] : null;
|
|
$decision = $event->event_type === 'partnership_interest.changed.v1';
|
|
$after = $submitted;
|
|
if ($decision) {
|
|
$after = array_replace($previous ?? [], ['version' => $data['interest_version'] ?? null, 'status' => $data['to'] ?? null, 'assessment' => $data['assessment'] ?? null, 'decision_reason' => $data['reason'] ?? null]);
|
|
}
|
|
$beforeView = $this->snapshot($previous, $actor, $interest, $responsible);
|
|
$afterView = $this->snapshot($after, $actor, $interest, $responsible);
|
|
$canRead = $responsible || $beforeView['availability'] === 'available' || $afterView['availability'] === 'available';
|
|
$version = is_int($data['interest_version'] ?? null) && $data['interest_version'] > 0 ? $data['interest_version'] : null;
|
|
if (! $canRead) {
|
|
return ['version' => $version, 'restricted' => true, 'action' => null, 'actor_name' => null, 'occurred_at' => null, 'reason' => null, 'previous' => $beforeView, 'after' => $afterView, 'matching' => null, 'partnership_version' => null];
|
|
}
|
|
$action = $decision ? ($data['action'] ?? null) : 'submit';
|
|
|
|
return ['version' => $version, 'restricted' => false, 'action' => is_string($action) && in_array($action, ['submit', 'assess', 'clarify', 'shortlist', 'reject', 'connect', 'withdraw'], true) ? $action : null,
|
|
'actor_name' => $this->person((int) $event->actor_id), 'occurred_at' => $event->occurred_at, 'reason' => is_string($data['reason'] ?? null) ? $data['reason'] : null,
|
|
'previous' => $beforeView, 'after' => $afterView, 'matching' => $afterView['availability'] === 'available' ? $this->matching($data['matching_context'] ?? null) : null,
|
|
'partnership_version' => is_int($data['partnership_version'] ?? null) ? $data['partnership_version'] : (is_int($envelope['publication_version'] ?? null) ? $envelope['publication_version'] : null)];
|
|
}
|
|
|
|
/** @param array<string,mixed>|null $snapshot
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function snapshot(?array $snapshot, User $actor, stdClass $interest, bool $responsible): array
|
|
{
|
|
if ($snapshot === null) {
|
|
return ['availability' => 'not_recorded'];
|
|
}
|
|
$scopeKnown = array_key_exists('organization_id', $snapshot) && ($snapshot['organization_id'] === null || is_string($snapshot['organization_id']) && Str::isUuid($snapshot['organization_id']));
|
|
if (! $responsible && ! $scopeKnown) {
|
|
return ['availability' => 'scope_unknown'];
|
|
}
|
|
if (! $responsible && ! app(PartnershipExchange::class)->owns($actor, (object) ['organization_id' => $snapshot['organization_id'], 'user_id' => $interest->user_id], false)) {
|
|
return ['availability' => 'restricted'];
|
|
}
|
|
$organization = $scopeKnown && is_string($snapshot['organization_id']) ? $snapshot['organization_id'] : null;
|
|
$payload = is_array($snapshot['payload'] ?? null) ? $snapshot['payload'] : null;
|
|
$fields = [];
|
|
foreach (['proposal', 'decision_reason'] as $key) {
|
|
if (array_key_exists($key, $snapshot) && ($snapshot[$key] === null || is_string($snapshot[$key]))) {
|
|
$fields[$key] = $snapshot[$key];
|
|
}
|
|
}
|
|
foreach (['capacity', 'availability', 'contribution', 'conflicts'] as $key) {
|
|
if ($payload !== null && array_key_exists($key, $payload) && ($payload[$key] === null || is_string($payload[$key]))) {
|
|
$fields[$key] = $payload[$key];
|
|
}
|
|
}
|
|
$consent = $payload !== null && array_key_exists('consent', $payload) ? $payload['consent'] : null;
|
|
$knownConsent = in_array($consent, [true, false, 0, 1, '0', '1', 'yes', 'on'], true);
|
|
$references = [];
|
|
foreach (['sector_id', 'territory_id'] as $key) {
|
|
if ($payload !== null && (is_string($payload[$key] ?? null) || is_int($payload[$key] ?? null))) {
|
|
$references[$key] = (string) $payload[$key];
|
|
}
|
|
}
|
|
|
|
return ['availability' => 'available', 'version' => is_int($snapshot['version'] ?? null) ? $snapshot['version'] : null,
|
|
'status' => is_string($snapshot['status'] ?? null) ? $snapshot['status'] : null, 'scope_known' => $scopeKnown, 'organization_id' => $organization,
|
|
'organization_name' => $organization ? $this->organization($organization) : null,
|
|
'fields' => $fields, 'consent_known' => $knownConsent, 'consent' => $knownConsent ? in_array($consent, [true, 1, '1', 'yes', 'on'], true) : null,
|
|
'assessment_recorded' => is_array($snapshot['assessment'] ?? null), 'assessment' => $this->assessment($snapshot['assessment'] ?? null), 'references' => $references,
|
|
'complete' => array_key_exists('proposal', $fields) && $scopeKnown && $payload !== null && is_array($snapshot['assessment'] ?? null)];
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
private function assessment(mixed $value): array
|
|
{
|
|
$items = [];
|
|
foreach (is_array($value) ? $value : [] as $code => $entry) {
|
|
if (! is_array($entry) || ! is_string($code)) {
|
|
continue;
|
|
}
|
|
$label = config('partnerships.criteria.'.$code, $code);
|
|
$items[] = ['code' => $code, 'label' => is_string($label) ? $label : $code, 'label_source' => 'current_catalogue',
|
|
'state' => is_string($entry['state'] ?? null) ? $entry['state'] : null, 'evidence' => is_string($entry['evidence'] ?? null) ? $entry['evidence'] : null,
|
|
'actor_name' => is_int($entry['assessed_by'] ?? null) ? $this->person($entry['assessed_by']) : null, 'assessed_at' => is_string($entry['assessed_at'] ?? null) ? $entry['assessed_at'] : null];
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
private function matching(mixed $value): ?array
|
|
{
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
$criteria = [];
|
|
foreach (is_array($value['criteria'] ?? null) ? $value['criteria'] : [] as $criterion) {
|
|
if (! is_array($criterion) || ! is_string($criterion['code'] ?? null)) {
|
|
continue;
|
|
}
|
|
$projected = ['code' => $criterion['code'], 'mandatory' => is_bool($criterion['mandatory'] ?? null) ? $criterion['mandatory'] : null,
|
|
'weight' => is_int($criterion['weight'] ?? null) || is_float($criterion['weight'] ?? null) ? $criterion['weight'] : null];
|
|
foreach (['label', 'state', 'evidence', 'explanation'] as $field) {
|
|
$projected[$field] = is_string($criterion[$field] ?? null) ? $criterion[$field] : null;
|
|
}
|
|
$criteria[] = $projected;
|
|
}
|
|
$matching = ['criteria' => $criteria];
|
|
foreach (['algorithm_version', 'weights_hash', 'eligibility'] as $field) {
|
|
$matching[$field] = is_string($value[$field] ?? null) ? $value[$field] : null;
|
|
}
|
|
foreach (['score_min', 'score_max', 'unknown_weight'] as $field) {
|
|
$matching[$field] = is_int($value[$field] ?? null) || is_float($value[$field] ?? null) ? $value[$field] : null;
|
|
}
|
|
|
|
return $matching;
|
|
}
|
|
|
|
private function person(int $id): ?string
|
|
{
|
|
if (! array_key_exists($id, $this->people)) {
|
|
$this->people[$id] = DB::table('users')->where('id', $id)->value('name');
|
|
}
|
|
|
|
return $this->people[$id];
|
|
}
|
|
|
|
private function organization(string $id): ?string
|
|
{
|
|
if (! array_key_exists($id, $this->organizations)) {
|
|
$this->organizations[$id] = DB::table('organizations')->where('id', $id)->value('name');
|
|
}
|
|
|
|
return $this->organizations[$id];
|
|
}
|
|
}
|