feat(editorial): stage portfolio base data and territory for atomic publication
This commit is contained in:
@@ -2,10 +2,15 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -17,6 +22,12 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
*/
|
||||
final class PortalEditorialWorkflow
|
||||
{
|
||||
private const STRUCTURE_FIELDS = [
|
||||
'projects' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version'],
|
||||
'economic_clusters' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version'],
|
||||
'development_corridors' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version'],
|
||||
];
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return (bool) config('editorial_workflow.enabled') && Schema::hasTable('portal_editorial_editions') && Schema::hasColumn('portal_editorial_notifications', 'opportunity_id');
|
||||
@@ -338,6 +349,53 @@ final class PortalEditorialWorkflow
|
||||
return $copy;
|
||||
}
|
||||
|
||||
public function structureDossier(string $structure, string $id, bool $write = false): ?InvestmentDossier
|
||||
{
|
||||
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$structure] ?? null;
|
||||
if (! $foreign) {
|
||||
return null;
|
||||
}
|
||||
$dossier = ($write ? InvestmentDossier::withTrashed() : InvestmentDossier::query())->where($foreign, $id)->first();
|
||||
if (! $dossier) {
|
||||
return null;
|
||||
}
|
||||
$enabled = $write ? $this->portfolioWrites($dossier->id) : $this->portfolioAvailable();
|
||||
abort_if($enabled && $write && $dossier->trashed(), 409, 'A ficha associada está arquivada. Recupere o contexto antes de alterar os dados-base.');
|
||||
|
||||
return $enabled ? $dossier : null;
|
||||
}
|
||||
|
||||
public function workingStructure(Model $record, InvestmentDossier $dossier): Model
|
||||
{
|
||||
$copy = clone $record;
|
||||
$edition = $this->latestPortfolio($dossier->id);
|
||||
if ($edition && $edition->published_at === null) {
|
||||
$intent = $this->payload($edition)['resource']['_structure'] ?? null;
|
||||
if ($intent) {
|
||||
abort_unless($intent['id'] === $record->getKey() && $intent['table'] === $record->getTable(), 409);
|
||||
$copy->forceFill($intent['fields']);
|
||||
}
|
||||
}
|
||||
$copy->setAttribute('editorial_version', (int) $this->workingPortfolio($dossier)->getAttribute('version_no'));
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $changes */
|
||||
public function saveStructure(User $actor, Model $record, InvestmentDossier $dossier, int $expected, array $changes): \stdClass
|
||||
{
|
||||
$this->authorizePortfolio($actor, $dossier->id, true);
|
||||
|
||||
return DB::transaction(function () use ($actor, $record, $dossier, $expected, $changes) {
|
||||
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
||||
$edition = $this->latestPortfolio($dossier->id);
|
||||
$prior = $edition && $edition->published_at === null ? ($this->payload($edition)['resource']['_structure']['fields'] ?? []) : [];
|
||||
$allowed = self::STRUCTURE_FIELDS[$record->getTable()] ?? abort(422);
|
||||
|
||||
return $this->savePortfolio($actor, $dossier, $expected, ['_structure' => ['table' => $record->getTable(), 'id' => $record->getKey(), 'fields' => array_replace($prior, Arr::only($changes, $allowed))]]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields
|
||||
* @param list<int> $contributors
|
||||
* @return EditorialPayload
|
||||
@@ -356,6 +414,23 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
}
|
||||
abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.');
|
||||
if ($intent = $fields['_structure'] ?? null) {
|
||||
abort_unless($intent['id'] === $links[0]['target_key'] && $intent['table'] === $links[0]['target_type'], 422, 'Alteração de dados-base fora do contexto da ficha.');
|
||||
abort_unless(array_diff(array_keys($intent['fields']), self::STRUCTURE_FIELDS[$intent['table']]) === [], 422, 'Campos fora do âmbito da estrutura.');
|
||||
if (array_key_exists('primary_territory_id', $intent['fields'])) {
|
||||
$territory = $intent['fields']['primary_territory_id'];
|
||||
$path = $territory ? app(InvestmentGeography::class)->path($territory, true) : [];
|
||||
abort_if($territory && $path === [], 422, 'Localização proposta indisponível.');
|
||||
abort_if(count($path) > 4, 422, 'Hierarquia territorial proposta inválida.');
|
||||
$context = [];
|
||||
foreach ($path as $index => $node) {
|
||||
$location = DB::table('geo_locations')->where('id', $node->id)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first();
|
||||
abort_unless($location && $location->location_type === ['province', 'district', 'admin_post', 'locality'][$index], 422, 'Hierarquia territorial proposta indisponível.');
|
||||
$context[] = Arr::only(get_object_vars($location), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'external_id', 'source_code']);
|
||||
}
|
||||
$fields['_structure']['territory_context'] = $context;
|
||||
}
|
||||
}
|
||||
|
||||
return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]];
|
||||
}
|
||||
@@ -385,8 +460,11 @@ final class PortalEditorialWorkflow
|
||||
'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
$base = $previous ? $this->payload($previous) : $public;
|
||||
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content']), ['version_no' => $expected + 1]);
|
||||
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? $base['contributors'] : []), $actor->id]));
|
||||
if ($previous && $previous->published_at !== null) {
|
||||
unset($base['resource']['_structure']);
|
||||
}
|
||||
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content', '_structure']), ['version_no' => $expected + 1]);
|
||||
$contributors = array_values(array_unique([...($previous && $previous->published_at === null ? $base['contributors'] : []), $actor->id]));
|
||||
$snapshot = $this->portfolioSnapshot($fields, $contributors);
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1,
|
||||
@@ -400,8 +478,30 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
|
||||
/** @param EditorialPayload $payload */
|
||||
private function publishPortfolio(\stdClass $record, \stdClass $edition, array $payload): void
|
||||
private function publishPortfolio(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
|
||||
{
|
||||
if ($intent = $payload['resource']['_structure'] ?? null) {
|
||||
abort_unless(in_array($intent['table'], ['projects', 'economic_clusters', 'development_corridors'], true), 422);
|
||||
$model = ['projects' => Project::class, 'economic_clusters' => EconomicCluster::class, 'development_corridors' => DevelopmentCorridor::class][$intent['table']];
|
||||
$target = $model::whereKey($intent['id'])->lockForUpdate()->first();
|
||||
$territories = array_column($intent['territory_context'] ?? [], 'id');
|
||||
if ($territories !== []) {
|
||||
DB::table('geo_locations')->whereIn('id', $territories)->orderBy('id')->lockForUpdate()->get();
|
||||
}
|
||||
abort_unless($target !== null && hash_equals($edition->content_hash, $this->hash($this->refreshPayload($edition, $payload))), 409, 'O contexto foi alterado. Obtenha nova aprovação.');
|
||||
try {
|
||||
PortalAudit::save($target, $intent['fields'], $actor->id);
|
||||
if (array_key_exists('primary_territory_id', $intent['fields'])) {
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $intent['table'], 'actor_user_id' => $actor->id, 'action' => 'primary_territory_saved',
|
||||
'after_data' => json_encode(['record_id' => $intent['id'], 'territory_id' => $intent['fields']['primary_territory_id'], 'version' => $intent['fields']['territory_version']], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
} catch (QueryException $exception) {
|
||||
if (in_array($exception->errorInfo[0] ?? '', ['23505', '23514'], true)) {
|
||||
abort(409, 'Os dados-base deixaram de ser aplicáveis ou o código já está em uso. Guarde nova edição antes de publicar.');
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
$approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
|
||||
$fields = Arr::only($payload['resource'], ['title', 'source', 'content']);
|
||||
$fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR);
|
||||
@@ -512,7 +612,7 @@ final class PortalEditorialWorkflow
|
||||
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
|
||||
}
|
||||
if ($portfolio) {
|
||||
$this->publishPortfolio($r, $e, $payload);
|
||||
$this->publishPortfolio($actor, $r, $e, $payload);
|
||||
} else {
|
||||
$this->publish($actor, $r, $e, $payload);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user