Files
Investidor-V2/app/Services/ClusterCompetitiveness.php
T
2026-11-14 11:15:09 +02:00

264 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\EconomicCluster;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ClusterCompetitiveness
{
public const FORCES = ['rivalry', 'entrants', 'substitutes', 'suppliers', 'buyers'];
public const FIELDS = ['evidence', 'source', 'period', 'rationale', 'limitations', 'opportunity'];
private const SOURCE = 'cluster-competitiveness';
private function events(string $cluster): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)
->where('entity_type', 'economic_clusters')->where('entity_id', $cluster);
}
/**
* @return array<array-key, mixed>
*/
private function decode(?\stdClass $event): array
{
return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR)
: ['version' => 0, 'draft' => null, 'approved' => null];
}
/**
* @return array<array-key, mixed>
*/
public function current(string $cluster): array
{
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
}
/**
* @return array<array-key, mixed>
*/
public function management(string $cluster): array
{
$state = $this->current($cluster);
$state['editorial_enabled'] = $this->editorialEnabled();
if (isset($state['workflow'])) {
$record = EconomicCluster::findOrFail($cluster);
$state['workflow']['fresh'] = hash_equals($state['workflow']['hash'], $this->editionHash($record, $state['draft']));
$state['workflow']['independent_required'] = $this->independentRequired($state['workflow']);
}
$state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get()
->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all();
return $state;
}
/**
* @param array<array-key, mixed> $draft
* @return array<array-key, mixed>
*/
public function validateDraft(array $draft, string $cluster): array
{
$rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster,
'sector' => 'present|nullable|string|max:200', 'region' => 'present|nullable|string|max:1000',
'forces' => 'required|array:'.implode(',', self::FORCES)];
foreach (self::FORCES as $force) {
$rules["forces.$force"] = 'required|array:pressure,'.implode(',', self::FIELDS);
$rules["forces.$force.pressure"] = 'present|nullable|in:low,medium,high';
foreach (self::FIELDS as $field) {
$rules["forces.$force.$field"] = 'present|nullable|string|max:5000';
}
}
$clean = Validator::make($draft, $rules)->validate();
$clean['version'] = 1;
$clean['sector'] = trim($clean['sector'] ?? '');
$clean['region'] = trim($clean['region'] ?? '');
foreach (self::FORCES as $force) {
foreach (['pressure', ...self::FIELDS] as $field) {
$clean['forces'][$force][$field] = trim($clean['forces'][$force][$field] ?? '');
}
}
return $clean;
}
/**
* @param array<array-key, mixed>|null $draft
*/
private function assertComplete(?array $draft): void
{
$missing = ! $draft || ! $draft['sector'] || ! $draft['region'];
foreach (self::FORCES as $force) {
$row = $draft['forces'][$force] ?? [];
$missing = $missing || ! in_array($row['pressure'] ?? '', ['low', 'medium', 'high'], true);
foreach (self::FIELDS as $field) {
$missing = $missing || trim($row[$field] ?? '') === '';
}
}
if ($missing) {
throw ValidationException::withMessages(['approval' => 'Complete o âmbito e as evidências, fontes, períodos, justificação, limitações e oportunidades das cinco forças antes de aprovar.']);
}
}
/** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields.
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
{
abort_if(! in_array($action, ['saved', 'restored', 'approved', 'withdrawn'], true) && ! $this->editorialEnabled(), 503, 'Circuito editorial dos clusters ainda não activado.');
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
$cluster = EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$before = $this->current($cluster->id);
abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.');
$state = $before;
if ($this->editorialEnabled() || isset($before['workflow'])) {
abort_unless($this->editorialEnabled(), 503, 'Reactive o circuito editorial para continuar esta análise; o histórico e a publicação foram conservados.');
$state = $this->editorialChange($cluster, $actor, $action, $input, $before);
} elseif ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event !== null, 404);
$state['draft'] = $this->decode($event)['draft'];
} elseif ($action === 'approved') {
$this->assertComplete($state['draft']);
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actor,
'version' => $expected + 1, 'evidence' => $input['reason']];
} elseif ($action === 'withdrawn') {
$state['approved'] = null;
} else {
throw new \LogicException('Unsupported competitiveness action');
}
$state['version']++;
$eventId = (string) Str::uuid();
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version' => $state['version'], 'editorial_state' => $state['workflow']['state'] ?? null, 'restored_from' => $input['revision'] ?? null,
'reason' => Crypt::encryptString($input['reason'] ?? ''),
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
if ($this->editorialEnabled()) {
$user = User::findOrFail($actor);
app(PortalEditorialNativeNotices::class)->emit($user, $eventId, $state['workflow']['contributors']);
}
return $this->management($cluster->id);
});
}
/**
* @return array<array-key, mixed>|null
*/
public function publicData(string $cluster): ?array
{
$state = $this->current($cluster);
$approved = isset($state['workflow']) ? $state['published'] : $state['approved'];
// Explicit projection: never return editor identity, approval evidence, history or the unapproved draft.
return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
}
public function editorialEnabled(): bool
{
return (bool) config('editorial_workflow.cluster_competitiveness');
}
/** @param array<array-key,mixed>|null $draft */
private function editionHash(EconomicCluster $cluster, ?array $draft): string
{
$context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']);
return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR));
}
/** @param array<array-key,mixed> $input
* @param array<array-key,mixed> $state
* @return array<array-key,mixed>
*/
private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array
{
$actor = User::whereKey($actorId)->firstOrFail();
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
if (! isset($state['workflow'])) {
abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.');
// An accepted legacy publication remains public; no retrospective approval is invented.
$state['published'] = $state['approved'];
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null,
'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false];
}
$workflow = $state['workflow'];
if (in_array($action, ['saved', 'restored'], true)) {
if ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
$restoredContributors = [];
} else {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event !== null, 404);
$restored = $this->decode($event);
$state['draft'] = $restored['draft'];
$restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id];
}
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1,
'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])),
'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null,
'independent_required' => $this->independentRequired($workflow)];
return $state;
}
validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate();
$allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'],
'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.');
if (in_array($action, ['submitted', 'approved', 'published'], true)) {
abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.');
}
if ($action === 'submitted' || $action === 'approved') {
$this->assertComplete($state['draft']);
}
if ($action === 'approved') {
abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.');
$workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']];
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']];
}
if ($action === 'published') {
$decision = $workflow['approval'];
abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.');
$reviewer = User::whereKey($decision['actor'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.');
abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.');
$state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1];
}
if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) {
$state['published'] = null;
}
$workflow['state'] = match ($action) {
'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected',
'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'],
};
$state['workflow'] = $workflow;
return $state;
}
/** @param array<array-key,mixed> $workflow */
private function independentRequired(array $workflow): bool
{
return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true);
}
}