Files
Investidor-V2/app/Services/PartnershipExchange.php
T
nala bdd766c68a
tests / ci (push) Canceled after 0s
resolvemos o problema dos ZEE e ZFI
2026-10-07 19:46:35 +02:00

242 lines
19 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use stdClass;
class PartnershipExchange
{
public function represents(User $actor, ?string $organization, bool $write = true): bool
{
return $organization !== null && DB::table('memberships')->where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at')
->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists()
&& DB::table('organizations')->where('id', $organization)->whereNull('deleted_at')->exists();
}
public function owns(User $actor, stdClass $record, bool $write = true): bool
{
// An organization record follows its current representatives, including the original author.
return $record->organization_id ? $this->represents($actor, $record->organization_id, $write) : (int) $record->user_id === $actor->id;
}
public function find(int $id, bool $lock = false): stdClass
{
$record = DB::table('portal_partnerships')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
abort_unless($record !== null, 404);
return $record;
}
/** @param array<string,mixed> $data */
public function save(User $actor, array $data, ?int $id = null): stdClass
{
$data = Validator::make($data, [
'client_key' => [$id ? 'nullable' : 'required', 'uuid'], 'version' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
'title' => ['nullable', 'string', 'max:200'], 'offer' => ['nullable', 'string', 'max:15000'], 'seeking' => ['nullable', 'string', 'max:15000'],
'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))],
'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')],
'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:summary,requirements,mandatory,confidentiality,consent,availability'],
'payload.summary' => ['nullable', 'string', 'max:15000'], 'payload.requirements' => ['nullable', 'string', 'max:10000'],
'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))],
'payload.confidentiality' => ['in:public,private'], 'payload.consent' => ['boolean'], 'payload.availability' => ['nullable', 'string', 'max:1000'],
'locations' => ['array', 'max:30'], 'locations.*.geo_location_id' => ['required', 'uuid'], 'locations.*.role' => ['required', 'in:primary,operating,coverage'],
'locations.*.source_reference' => ['required', 'string', 'max:2000'],
'origins' => ['array', 'max:20'], 'origins.*.type' => ['required', 'string'], 'origins.*.target_id' => ['required', 'string', 'max:100'],
])->validate();
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.');
$creationHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $data, $id, $creationHash) {
DB::select('select pg_advisory_xact_lock(71026, ?)', [$actor->id]);
if ($id) {
$record = $this->find($id, true);
abort_unless($this->owns($actor, $record), 403);
abort_unless((int) $record->version === (int) $data['version'], 409, 'Rascunho alterado; recarregue.');
abort_unless(in_array($record->status, ['draft', 'returned', 'rejected', 'published'], true), 409, 'Estado não permite edição.');
} else {
$existing = DB::table('portal_partnerships')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first();
if ($existing) {
abort_unless((json_decode($existing->payload, true)['_creation_hash'] ?? null) === $creationHash, 409, 'Chave de rascunho já utilizada com outro conteúdo.');
return $existing;
}
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $actor->id, 'client_key' => $data['client_key'], 'title' => '', 'sector' => '', 'province' => '', 'offer' => '', 'seeking' => '', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]);
$record = $this->find($id, true);
}
$origins = $data['origins'] ?? [];
foreach ($origins as $origin) {
app(PartnershipSources::class)->resolve($origin['type'], $origin['target_id'], $actor);
}
$locations = $data['locations'] ?? [];
foreach ($locations as $location) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location['geo_location_id'])->exists(), 422, 'Localização não pertence ao cadastro territorial público.');
}
DB::table('portal_partnerships')->where('id', $id)->update(['title' => $data['title'] ?? '', 'offer' => $data['offer'] ?? '', 'seeking' => $data['seeking'] ?? '',
'organization_id' => $data['organization_id'] ?? null, 'sector_id' => $data['sector_id'] ?? null, 'kind' => $data['kind'], 'partnership_type' => $data['partnership_type'] ?? null,
'deadline' => $data['deadline'] ?? null, 'payload' => json_encode([...($data['payload'] ?? []), '_creation_hash' => json_decode($record->payload, true)['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR),
'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]);
DB::table('partnership_origins')->where('partnership_id', $id)->delete();
foreach ($origins as $origin) {
DB::table('partnership_origins')->insertOrIgnore(['partnership_id' => $id, 'type' => $origin['type'], 'target_id' => $origin['target_id']]);
}
DB::table('partnership_locations')->where('partnership_id', $id)->delete();
foreach ($locations as $location) {
DB::table('partnership_locations')->insertOrIgnore(['partnership_id' => $id, 'geo_location_id' => $location['geo_location_id'], 'role' => $location['role'], 'source_reference' => $location['source_reference']]);
}
$saved = $this->find($id);
$this->event($saved, $actor, 'partnership_opportunity.changed.v1', ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations], 'Rascunho guardado', [$record->user_id]);
return $saved;
});
}
public function transition(User $actor, int $id, int $version, string $action, string $reason): stdClass
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason) {
$record = $this->find($id, true);
abort_unless((int) $record->version === $version, 409, 'Proposta alterada; recarregue.');
$moderation = in_array($action, ['publish', 'return', 'reject', 'suspend'], true);
if ($moderation) {
Gate::forUser($actor)->authorize('manage-portal');
abort_if($this->owns($actor, $record), 403, 'Não pode moderar a própria proposta.');
} else {
abort_unless($this->owns($actor, $record), 403);
}
$from = $record->status;
$allowed = ['submit' => ['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'],
'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($from, $allowed[$action], true), 409, 'Transição inválida.');
if ($action === 'submit') {
$payload = json_decode($record->payload, true);
Validator::make(['title' => $record->title, 'type' => $record->partnership_type, 'offer' => $record->offer, 'seeking' => $record->seeking, 'consent' => $payload['consent'] ?? false],
['title' => 'required', 'type' => 'required', 'offer' => 'required_without:seeking', 'seeking' => 'required_without:offer', 'consent' => 'accepted'])->validate();
}
$status = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft'][$action];
DB::table('portal_partnerships')->where('id', $id)->update(['status' => $status, 'version' => $version + 1, 'reviewed_by' => $moderation ? $actor->id : $record->reviewed_by,
'published_at' => $action === 'publish' ? now() : $record->published_at, 'updated_at' => now()]);
$saved = $this->find($id);
$this->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', ['from' => $from, 'to' => $status, 'reason' => $reason], 'Proposta: '.$status, [$record->user_id]);
return $saved;
});
}
/** @param array<string,mixed> $data */
public function interest(User $actor, int $id, array $data): stdClass
{
$data = Validator::make($data, ['version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'],
'payload' => ['array:capacity,availability,contribution,conflicts,consent,sector_id,territory_id'], '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', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id']])->validate();
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403);
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->find($id, true);
abort_unless($record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString()), 409, 'Receção de interesses encerrada.');
abort_if((int) $record->user_id === $actor->id || $this->owns($actor, $record, false) || ($data['organization_id'] ?? null) && $data['organization_id'] === $record->organization_id, 403, 'Auto-candidatura não permitida.');
$existing = 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()->first();
if ($data['organization_id'] ?? null) {
abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $data['organization_id'])->where('status', '!=', 'withdrawn')
->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.');
}
if ($existing) {
abort_unless($this->owns($actor, $existing), 403);
abort_unless(isset($data['version']) && (int) $existing->version === (int) $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.');
abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409);
$interestId = $existing->id;
DB::table('partnership_interests')->where('id', $interestId)->update(['proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'organization_id' => $data['organization_id'] ?? null,
'status' => 'submitted', 'assessment' => '{}', 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
$interestId = DB::table('partnership_interests')->insertGetId(['partnership_id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null,
'proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'created_at' => now(), 'updated_at' => now()]);
}
$this->event($record, $actor, 'partnership_interest.submitted.v1', ['interest_id' => $interestId, 'interest_version' => $existing ? $existing->version + 1 : 1], 'Manifestação de interesse recebida', [$record->user_id, $actor->id]);
return DB::table('partnership_interests')->where('id', $interestId)->first();
});
}
/** @param array<string,mixed> $assessment */
public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = []): stdClass
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment) {
$interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest !== null, 404);
$record = $this->find($interest->partnership_id, true);
abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.');
if ($action === 'withdraw') {
abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.');
abort_unless($this->owns($actor, $interest), 403);
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
} else {
abort_unless($this->owns($actor, $record), 403);
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
}
$states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status];
abort_unless(isset($states[$action]), 422);
foreach ($assessment as $code => $criterion) {
abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422);
Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate();
$assessment[$code]['assessed_by'] = $actor->id;
$assessment[$code]['assessed_at'] = now()->toIso8601String();
}
$assessed = array_replace(json_decode($interest->assessment, true), $assessment);
if (in_array($action, ['shortlist', 'connect'], true)) {
$match = app(PartnershipMatching::class)->explain($record, $interest, $assessed);
abort_if($match['eligibility'] === '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), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]);
$this->event($record, $actor, 'partnership_interest.changed.v1', ['interest_id' => $id, 'interest_version' => $version + 1, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed], 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]);
return DB::table('partnership_interests')->where('id', $id)->first();
});
}
/** @return array{room:stdClass,interest:stdClass,partnership:stdClass} */
public function room(User $actor, int $id, bool $write = false): array
{
$room = DB::table('partnership_rooms')->where('id', $id)->first();
abort_unless($room !== null, 404);
$interest = DB::table('partnership_interests')->where('id', $room->interest_id)->first();
$record = $this->find($interest->partnership_id);
abort_unless($this->owns($actor, $record, $write) || $this->owns($actor, $interest, $write), 403, 'Sala privada reservada aos participantes.');
abort_if($write && in_array($room->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
return ['room' => $room, 'interest' => $interest, 'partnership' => $record];
}
/** @param array<string,mixed> $payload
* @param list<int> $recipients
*/
public function event(stdClass $record, User $actor, string $type, array $payload, string $title, array $recipients = []): void
{
DB::transaction(function () use ($record, $actor, $type, $payload, $title, $recipients): void {
DB::select('select pg_advisory_xact_lock(hashtextextended(?,0))', ['partnership-events:'.$record->id]);
$sequence = (DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version') ?? 0) + 1;
$eventId = (string) Str::uuid();
$envelope = ['event_id' => $eventId, 'event_type' => $type, 'event_version' => 1, 'source' => 'investmoz.partnerships', 'subject_id' => $record->id,
'occurred_at' => now()->toIso8601String(), 'producer_version' => '1.0', 'correlation_id' => $eventId, 'causation_id' => null, 'data_classification' => 'internal',
'aggregate_id' => $record->id, 'aggregate_version' => $sequence, 'publication_version' => $record->version, 'payload' => $payload, 'external_delivery' => 'not_configured'];
DB::table('partnership_events')->insert(['id' => $eventId, 'partnership_id' => $record->id, 'actor_id' => $actor->id, 'event_type' => $type, 'aggregate_version' => $sequence, 'payload' => json_encode($envelope)]);
DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id,
'action' => $type, 'after_data' => json_encode($envelope)]);
foreach (array_unique($recipients) as $recipient) {
DB::table('partnership_notifications')->insert(['event_id' => $eventId, 'user_id' => $recipient, 'title' => $title, 'href' => '/parcerias/'.$record->id]);
}
});
}
}