189 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|