131 lines
6.3 KiB
PHP
131 lines
6.3 KiB
PHP
<?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['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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|