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

297 lines
18 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Availability, reports and administrative plans in the existing opportunity and CRM tasks. */
final class PrivateOfferOperations
{
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function afterApproval(array $context): array
{
$days = config('private_offers.validity_days');
$reference = config('private_offers.validity_policy_reference');
if ($days !== null && (! is_int($days) || $days < 1 || $days > 36500 || ! is_string($reference) || trim($reference) === '')) {
throw ValidationException::withMessages(['validity' => 'Configuração de validade incompleta. Não foi aplicado um prazo presumido.']);
}
$operation = $context['offer_operation'] ?? ['status' => 'available', 'cycle' => 0];
$hash = $context['approval']['hash'];
$policy = ['days' => $days, 'policy_reference' => $reference, 'approval_hash' => $hash];
$operation['validity_policies'][$hash] ??= $policy;
$operation['validity'] ??= $operation['validity_policies'][$hash];
$operation['last_approval_hash'] = $hash;
return $operation;
}
private function offer(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
return $record;
}
public function publisher(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id);
}
/** @return Builder<Opportunity> */
public function staffQuery(User $actor): Builder
{
$access = app(PortalProfileAccess::class);
abort_unless($access->mfa($actor) && ! $access->has($actor, 'auditor'), 403);
$grants = [...$access->grants($actor, 'validator'), ...$access->grants($actor, 'publisher')];
abort_unless($grants !== [], 403);
return $access->scope(Opportunity::query(), $actor)->whereNotNull('workflow_context->private_offer_preparation')
->where('owner_user_id', '!=', $actor->id)->whereRaw("NOT COALESCE(workflow_context->'contributors','[]'::jsonb) @> ?::jsonb", [json_encode([$actor->id])])
->where(fn ($q) => $q->whereNull('organization_id')->orWhereNotIn('organization_id', DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->select('organization_id')))
->where(function ($q) use ($grants) {
foreach ($grants as $grant) {
$q->orWhere(function ($scope) use ($grant) {
$scope->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
if ($grant->organization_id !== null) {
$scope->where('organization_id', $grant->organization_id);
}
});
}
});
}
/** @return array<string,mixed> */
public function presentation(User $actor, int $id): array
{
$record = app(PrivateOfferPreparation::class)->record($actor, $id);
$reviewer = app(PrivateOfferConfidentiality::class)->internal($actor, $record);
$publisher = $this->publisher($actor, $record);
$reports = [];
if ($reviewer) {
foreach ($record->workflow_context['offer_reports'] ?? [] as $report) {
$reports[] = [...$report, 'reason' => Crypt::decryptString($report['reason']), 'resolution_reason' => isset($report['resolution_reason']) ? Crypt::decryptString($report['resolution_reason']) : null,
'source_snapshot' => isset($report['source_snapshot']) ? json_decode(Crypt::decryptString($report['source_snapshot']), true, 512, JSON_THROW_ON_ERROR) : null];
}
}
$highlight = $record->workflow_context['offer_highlight'] ?? null;
return ['id' => $id, 'etag' => app(OpportunityManagement::class)->etag($record),
'availability' => app(OfferAvailability::class)->record($record), 'cycle' => $record->workflow_context['offer_operation']['cycle'] ?? 0,
'owner' => app(PrivateOfferConfidentiality::class)->seller($actor, $record), 'reviewer' => $reviewer, 'publisher' => $publisher,
'editorial_state' => app(PortalProfileAccess::class)->state($record), 'reports' => $reports,
'highlight' => $publisher && $highlight ? [...$highlight, 'note' => Crypt::decryptString($highlight['note'])] : null,
'commercial_activation' => false,
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'cycle'])), $record->workflow_context['offer_operation_history'] ?? [])];
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $metadata */
private function save(Opportunity $record, ?User $actor, array $context, string $action, array $metadata = []): void
{
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor?->id, 'at' => now()->toIso8601String(),
'cycle' => $context['offer_operation']['cycle'] ?? 0, ...$metadata];
$context['offer_operation_history'][] = $event;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
DB::table('audit_events')->insert(['source_system' => 'private-offers', 'entity_type' => 'opportunity', 'actor_user_id' => $actor?->id,
'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
'after_data' => json_encode(['record_id' => $record->id, 'record_version' => $record->management_version, ...$event], JSON_THROW_ON_ERROR)]);
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
private function withdraw(array $context, string $origin): array
{
$context['offer_operation'] = [...($context['offer_operation'] ?? ['cycle' => 0]), 'status' => 'withdrawn', 'withdrawn_at' => now()->toIso8601String(), 'origin' => $origin];
return $context;
}
/** @param array<string,mixed> $input */
public function ownerAction(User $actor, int $id, array $input): Opportunity
{
$data = validator($input, ['action' => 'required|in:withdraw,request_reactivation,reactivate', 'confirm' => 'required|accepted',
'etag' => 'required_unless:action,withdraw|string|size:64', 'reason' => 'nullable|string|max:1000'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->seller($actor, $record), 403);
$context = $record->workflow_context;
$state = app(OfferAvailability::class)->record($record)['state'];
if ($data['action'] === 'withdraw') {
if (($context['offer_operation']['status'] ?? null) === 'withdrawn') {
return $record;
}
$this->save($record, $actor, $this->withdraw($context, 'owner'), 'offer_withdrawn', ['reason' => Crypt::encryptString($data['reason'] ?? '')]);
return $record;
}
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
if ($data['action'] === 'request_reactivation') {
abort_unless(in_array($state, ['withdrawn', 'expired'], true), 409, 'A oferta não está retirada ou expirada.');
$editorialState = app(PortalProfileAccess::class)->state($record);
abort_unless(isset($context['submission']) || in_array($editorialState, ['draft', 'needs_information'], true), 409,
'A preparação anterior ainda precisa da vinculação editorial existente antes de abrir nova revisão.');
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'revalidation', 'cycle' => ($context['offer_operation']['cycle'] ?? 0) + 1,
'requested_by' => $actor->id, 'requested_at' => now()->toIso8601String()];
// Reopen the same editorial engine, retaining old submissions and decisions.
$record->workflow_context = $context;
$record->save();
if (isset($context['submission'])) {
$record = app(OpportunityProfileWorkflow::class)->transition($actor, $id, ['etag' => app(OpportunityManagement::class)->etag($record),
'action' => 'request_revision', 'reason' => 'Pedido explícito de nova validação para reactivar a oferta. '.($data['reason'] ?? '')]);
}
$this->save($record, $actor, $record->workflow_context, 'offer_reactivation_requested');
return $record;
}
abort_unless($state === 'revalidation' && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'É necessária nova validação editorial.');
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
abort_unless(($snapshot['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle']
&& hash_equals($context['approval']['hash'] ?? '', $snapshot['hash'])
&& ($context['submission']['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle'], 409);
$context['offer_operation']['status'] = 'available';
$context['offer_operation']['activated_hash'] = $snapshot['hash'];
$this->save($record, $actor, $context, 'offer_reactivated_privately');
return $record;
});
}
public function expire(int $id): bool
{
return DB::transaction(function () use ($id) {
$record = $this->offer($id, true);
$availability = app(OfferAvailability::class)->record($record);
if ($availability['state'] !== 'expired' || ($record->workflow_context['offer_operation']['status'] ?? null) === 'expired') {
return false;
}
$context = $record->workflow_context;
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'expired', 'expired_at' => $availability['expires_at']];
$this->save($record, null, $context, 'offer_expired', ['expires_at' => $availability['expires_at']]);
return true;
});
}
public function canReport(User $actor, Opportunity $record): bool
{
if (app(PortalProfileAccess::class)->canView($actor, $record)) {
return true;
}
try {
app(OfferPublicCatalogue::class)->detail($record->id);
return true;
} catch (HttpExceptionInterface $error) {
return false;
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function report(User $actor, int $id, array $input): array
{
$data = validator($input, ['client_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
$context = $record->workflow_context;
$key = $data['client_key'];
$hash = hash('sha256', $data['reason']);
if (isset($context['offer_reports'][$key])) {
$previous = $context['offer_reports'][$key];
abort_unless($previous['reporter_id'] === $actor->id, 404);
abort_unless($previous['command_hash'] === $hash, 409);
return ['id' => $key, 'replayed' => true];
}
abort_unless($this->canReport($actor, $record), 404);
try {
$source = app(OfferPublicCatalogue::class)->detail($id);
} catch (HttpExceptionInterface $error) {
$source = Arr::only(app(OpportunityManagement::class)->values($record), ['title', 'description', 'opportunity_category', 'private_offer']);
}
$task = app(OpportunityWorkflowTasks::class)->ensure($record, 'offer_report_'.$key, 'Denúncia da Oferta '.$record->reference);
$context['offer_reports'][$key] = ['id' => $key, 'reporter_id' => $actor->id, 'at' => now()->toIso8601String(), 'command_hash' => $hash,
'reason' => Crypt::encryptString($data['reason']), 'version' => 1, 'status' => 'open', 'task_id' => $task,
'source_snapshot' => Crypt::encryptString(json_encode($source, JSON_THROW_ON_ERROR)),
'source_record_version' => $record->management_version, 'source_approval_hash' => $context['approval']['hash'] ?? null,
'source_public_hash' => $context['offer_publication']['publication']['hash'] ?? null];
$this->save($record, $actor, $context, 'offer_reported', ['report_id' => $key]);
return ['id' => $key, 'replayed' => false];
});
}
/** @param array<string,mixed> $input */
public function handleReport(User $actor, int $id, string $reportId, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:start_review,resolve_no_withdrawal,resolve_withdrawal',
'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $reportId, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->internal($actor, $record), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
$context = $record->workflow_context;
$report = $context['offer_reports'][$reportId] ?? null;
abort_unless($report !== null, 404);
abort_unless($report['version'] === (int) $data['version'] && in_array($report['status'], ['open', 'in_review'], true), 409);
if ($data['action'] === 'start_review') {
abort_unless($report['status'] === 'open', 409);
app(OpportunityWorkflowTasks::class)->assign($record, $report['task_id'], $actor);
$report['status'] = 'in_review';
} else {
abort_unless($report['status'] === 'in_review' && $report['reviewer_id'] === $actor->id, 409, 'A denúncia deve ser tratada pelo revisor que a iniciou.');
app(OpportunityWorkflowTasks::class)->complete($report['task_id'], 'Denúncia tratada; decisão no histórico privado.');
$report['status'] = 'resolved';
$report['resolution'] = $data['action'];
if ($data['action'] === 'resolve_withdrawal') {
$context = $this->withdraw($context, 'moderation');
}
}
$report['reviewer_id'] = $actor->id;
$report['version']++;
$report['updated_at'] = now()->toIso8601String();
$report['resolution_reason'] = Crypt::encryptString($data['reason']);
$report['history'][] = ['action' => $data['action'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => Crypt::encryptString($data['reason'])];
$context['offer_reports'][$reportId] = $report;
$this->save($record, $actor, $context, 'offer_report_'.$data['action'], ['report_id' => $reportId]);
});
}
/** @param array<string,mixed> $input */
public function highlight(User $actor, int $id, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:0', 'status' => 'required|in:draft,archived',
'label' => 'required|string|max:150', 'starts_at' => 'nullable|date', 'ends_at' => 'nullable|required_with:starts_at|date|after:starts_at',
'note' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless($this->publisher($actor, $record), 403);
$context = $record->workflow_context;
$before = $context['offer_highlight'] ?? null;
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']) && (int) $data['version'] === ($before['version'] ?? 0), 409);
$plan = ['version' => (int) $data['version'] + 1, 'status' => $data['status'], 'label' => $data['label'], 'starts_at' => $data['starts_at'] ?? null,
'ends_at' => $data['ends_at'] ?? null, 'note' => Crypt::encryptString($data['note']), 'actor_id' => $actor->id, 'at' => now()->toIso8601String()];
$context['offer_highlight_history'][] = $before;
$context['offer_highlight'] = $plan;
$this->save($record, $actor, $context, 'offer_highlight_prepared', ['plan_version' => $plan['version'], 'status' => $plan['status']]);
});
}
}