where('source_system', self::SOURCE) ->where('entity_type', 'economic_clusters')->where('entity_id', $cluster); } /** * @return array */ 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 */ public function current(string $cluster): array { return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first()); } /** * @return array */ public function management(string $cluster): array { $state = $this->current($cluster); $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 $draft * @return array */ 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|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 $input * @return array */ public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array { return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) { 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 ($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']++; DB::table('audit_events')->insert(['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'], 'restored_from' => $input['revision'] ?? null, 'reason' => Crypt::encryptString($input['reason'] ?? ''), 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]); return $this->management($cluster->id); }); } /** * @return array|null */ public function publicData(string $cluster): ?array { $approved = $this->current($cluster)['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; } }