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

189 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use stdClass;
class PartnershipCandidacies
{
private const PAYLOAD = ['capacity', 'availability', 'contribution', 'conflicts', 'consent', 'sector_id', 'territory_id'];
public function accepting(stdClass $record): bool
{
return $record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString());
}
/** @return array<int,list<array{key:string,version:int,action:string}>> */
public function commands(User $actor, int $partnership): array
{
$commands = [];
$events = DB::table('partnership_events')->where('partnership_id', $partnership)->where('actor_id', $actor->id)
->whereIn('event_type', ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'])->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->get(['payload', 'event_type']);
foreach ($events as $event) {
$payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
$commands[(int) $payload['interest_id']][] = ['key' => $payload['client_key'], 'version' => (int) $payload['interest_version'], 'action' => $event->event_type === 'partnership_interest.submitted.v1' ? 'submit' : $payload['action']];
}
return $commands;
}
/** @param array<string,mixed> $data */
public function submit(User $actor, int $id, array $data): stdClass
{
$data = Validator::make($data, ['client_key' => ['nullable', 'uuid'], 'interest_id' => ['nullable', 'integer', 'min:1'], 'partnership_version' => ['nullable', 'integer', 'min:1'], 'version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'],
'payload' => ['array:'.implode(',', self::PAYLOAD)], 'payload.consent' => ['accepted'], 'payload.capacity' => ['nullable', 'string', 'max:5000'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'payload.contribution' => ['nullable', 'string', 'max:5000'], 'payload.conflicts' => ['nullable', 'string', 'max:5000'], 'payload.sector_id' => ['nullable', 'integer'], 'payload.territory_id' => ['nullable', 'uuid']])->validate();
if (isset($data['client_key'])) {
$data['client_key'] = strtolower($data['client_key']);
}
foreach (['interest_id', 'partnership_version', 'version'] as $integer) {
if (isset($data[$integer])) {
$data[$integer] = (int) $data[$integer];
}
}
$hash = $this->hash(Arr::except($data, 'client_key'));
return DB::transaction(function () use ($actor, $id, $data, $hash) {
$exchange = app(PartnershipExchange::class);
$record = $exchange->find($id, true);
$replay = $this->replay($actor, $record, 'partnership_interest.submitted.v1', $data['client_key'] ?? null, $hash);
if ($replay) {
abort_unless($exchange->owns($actor, $replay), 403);
return $replay;
}
if (isset($data['interest_id'])) {
$existing = DB::table('partnership_interests')->where('partnership_id', $id)->where('id', $data['interest_id'])->lockForUpdate()->first();
abort_unless($existing !== null, 404, 'A candidatura não pertence a esta parceria.');
} else {
$matches = DB::table('partnership_interests')->where('partnership_id', $id)->where(fn ($q) => $q->where('user_id', $actor->id)->when($data['organization_id'] ?? null, fn ($org) => $org->orWhere('organization_id', $data['organization_id'])))->orderBy('id')->lockForUpdate()->get();
abort_if($matches->count() > 1, 409, 'Há mais de uma candidatura possível. Abra a candidatura que pretende actualizar.');
$existing = $matches->first();
}
if ($existing) {
abort_unless($exchange->owns($actor, $existing), 403);
}
$organization = array_key_exists('organization_id', $data) ? $data['organization_id'] : ($existing->organization_id ?? null);
abort_if($organization && ! $exchange->represents($actor, $organization), 403);
abort_unless($this->accepting($record), 409, 'Receção de interesses encerrada.');
abort_if(isset($data['partnership_version']) && (int) $record->version !== $data['partnership_version'], 409, 'A proposta da parceria mudou. Compare os dados antes de submeter.');
abort_if((int) $record->user_id === $actor->id || $exchange->owns($actor, $record, false) || $organization && $organization === $record->organization_id, 403, 'Auto-candidatura não permitida.');
if ($organization) {
abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $organization)->where('status', '!=', 'withdrawn')->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.');
}
Validator::make($data, ['payload.sector_id' => ['nullable', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'exists:geo_locations,id']])->validate();
if ($existing) {
abort_unless(isset($data['version']) && (int) $existing->version === $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.');
abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409);
}
$before = $existing ? $this->snapshot($existing) : null;
$payload = array_replace($existing ? Arr::only(json_decode($existing->payload, true, 512, JSON_THROW_ON_ERROR), self::PAYLOAD) : [], $data['payload']);
$fields = ['proposal' => $data['proposal'], 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'organization_id' => $organization, 'status' => 'submitted', 'assessment' => '{}', 'version' => $existing ? (int) $existing->version + 1 : 1, 'updated_at' => now()];
if ($existing) {
$interestId = (int) $existing->id;
DB::table('partnership_interests')->where('id', $interestId)->update($fields);
} else {
$interestId = (int) DB::table('partnership_interests')->insertGetId([...$fields, 'partnership_id' => $id, 'user_id' => $actor->id, 'created_at' => now()]);
}
$saved = DB::table('partnership_interests')->where('id', $interestId)->first();
$metadata = ['interest_id' => $interestId, 'interest_version' => (int) $saved->version, 'partnership_version' => (int) $record->version, 'previous' => $before, 'submitted' => $this->snapshot($saved)];
if (isset($data['client_key'])) {
$metadata += ['client_key' => $data['client_key'], 'command_hash' => $hash];
}
$exchange->event($record, $actor, 'partnership_interest.submitted.v1', $metadata, 'Manifestação de interesse recebida', [$record->user_id, $actor->id]);
$saved->command_replayed = false;
return $saved;
});
}
/** @param array<string,mixed> $assessment */
public function decide(User $actor, int $id, int $version, string $action, string $reason, array $assessment = [], ?int $partnershipVersion = null, ?string $weightsHash = null, ?string $clientKey = null): stdClass
{
$clientKey = $clientKey === null ? null : strtolower($clientKey);
$hash = $this->hash(['interest_id' => $id, 'version' => $version, 'action' => $action, 'reason' => $reason, 'assessment' => $assessment, 'partnership_version' => $partnershipVersion, 'weights_hash' => $weightsHash]);
return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment, $partnershipVersion, $weightsHash, $clientKey, $hash) {
$parent = DB::table('partnership_interests')->where('id', $id)->first(['partnership_id']);
abort_unless($parent !== null, 404);
$exchange = app(PartnershipExchange::class);
$record = $exchange->find($parent->partnership_id, true);
$interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest !== null && (int) $interest->partnership_id === (int) $record->id, 409);
abort_unless($exchange->owns($actor, $action === 'withdraw' ? $interest : $record), 403);
if ($action === 'withdraw') {
abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.');
}
$replay = $this->replay($actor, $record, 'partnership_interest.changed.v1', $clientKey, $hash);
if ($replay) {
return $replay;
}
abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.');
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
if ($action !== 'withdraw') {
abort_if($partnershipVersion !== null && (int) $record->version !== $partnershipVersion, 409, 'A proposta ou os requisitos foram alterados. Compare antes de decidir.');
abort_if($weightsHash !== null && app(PartnershipMatching::class)->explain($record, $interest)['weights_hash'] !== $weightsHash, 409, 'Os critérios de correspondência foram alterados. Compare antes de decidir.');
}
$states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status];
abort_unless(isset($states[$action]), 422);
$before = $this->snapshot($interest);
foreach ($assessment as $code => $criterion) {
abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422);
$assessment[$code] = [...Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate(), 'assessed_by' => $actor->id, 'assessed_at' => now()->toIso8601String()];
}
$assessed = array_replace(json_decode($interest->assessment, true, 512, JSON_THROW_ON_ERROR), $assessment);
$matching = $action === 'withdraw' ? null : app(PartnershipMatching::class)->explain($record, $interest, $assessed);
if (in_array($action, ['shortlist', 'connect'], true)) {
abort_if(($matching['eligibility'] ?? null) === 'unmet', 422, 'Existe requisito obrigatório comprovadamente incumprido.');
}
if ($action === 'connect') {
abort_unless($interest->status === 'shortlisted', 409, 'Selecione primeiro a candidatura para lista curta.');
DB::table('partnership_rooms')->insertOrIgnore(['interest_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
DB::table('partnership_interests')->where('id', $id)->update(['status' => $states[$action], 'assessment' => json_encode($assessed, JSON_THROW_ON_ERROR), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]);
$saved = DB::table('partnership_interests')->where('id', $id)->first();
$metadata = ['interest_id' => $id, 'interest_version' => $version + 1, 'partnership_version' => (int) $record->version, 'action' => $action, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed, 'matching_context' => $matching, 'previous' => $before];
if ($clientKey !== null) {
$metadata += ['client_key' => $clientKey, 'command_hash' => $hash];
}
$exchange->event($record, $actor, 'partnership_interest.changed.v1', $metadata, 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]);
$saved->command_replayed = false;
return $saved;
});
}
/** @return array<string,mixed> */
private function snapshot(stdClass $interest): array
{
return ['version' => (int) $interest->version, 'status' => $interest->status, 'organization_id' => $interest->organization_id, 'proposal' => $interest->proposal, 'payload' => json_decode($interest->payload, true, 512, JSON_THROW_ON_ERROR), 'assessment' => json_decode($interest->assessment, true, 512, JSON_THROW_ON_ERROR), 'decision_reason' => $interest->decision_reason];
}
/** @param array<string,mixed> $command */
private function hash(array $command): string
{
return hash('sha256', json_encode(Arr::sortRecursive($command), JSON_THROW_ON_ERROR));
}
private function replay(User $actor, stdClass $record, string $type, ?string $key, string $hash): ?stdClass
{
if ($key === null) {
return null;
}
$event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', $type)->whereRaw("payload->'payload'->>'client_key' = ?", [$key])->first(['payload']);
if (! $event) {
return null;
}
$payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
abort_unless($payload['command_hash'] === $hash, 409, 'Chave já utilizada com outro conteúdo.');
$interest = DB::table('partnership_interests')->where('partnership_id', $record->id)->where('id', $payload['interest_id'])->first();
abort_unless($interest !== null, 409, 'A candidatura original não está disponível.');
$interest->command_replayed = true;
return $interest;
}
}