297 lines
18 KiB
PHP
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']]);
|
|
});
|
|
}
|
|
}
|