feat(clusters): persist versioned competitiveness and approved public analysis
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
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);
|
||||
}
|
||||
|
||||
private function decode(?object $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];
|
||||
}
|
||||
|
||||
public function current(string $cluster): array
|
||||
{
|
||||
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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['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;
|
||||
}
|
||||
|
||||
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. */
|
||||
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, 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);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user