Files
Investidor-V2/app/Services/TerritorialRegistry.php
T

80 lines
5.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
final class TerritorialRegistry
{
public const PARENTS = ['country' => null, 'province' => 'country', 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'];
public function published(): Builder
{
// Every ancestor must remain public, in force and backed by an approved source.
return DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->where('p.status', 'published')
->whereRaw("NOT EXISTS (WITH RECURSIVE ancestry AS (
SELECT l.id,l.parent_id,l.deleted_at,l.data_classification,ARRAY[l.id] AS path,false AS cycle FROM geo_locations l WHERE l.id=g.id
UNION ALL SELECT l.id,l.parent_id,l.deleted_at,l.data_classification,a.path||l.id,l.id=ANY(a.path) FROM geo_locations l JOIN ancestry a ON l.id=a.parent_id WHERE NOT a.cycle
) SELECT 1 FROM ancestry a LEFT JOIN geo_territory_profiles ap ON ap.geo_location_id=a.id LEFT JOIN geo_sources s ON s.id=ap.source_id
WHERE a.cycle OR a.deleted_at IS NOT NULL OR a.data_classification<>'public' OR ap.status IS DISTINCT FROM 'published' OR s.status IS DISTINCT FROM 'approved'
OR ap.valid_from>CURRENT_DATE OR ap.valid_to<CURRENT_DATE)");
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?string $id = null): string
{
abort_unless($actor->can('manage-portal') && $actor->two_factor_confirmed_at && $actor->two_factor_secret, 403);
abort_unless(in_array(strtolower($actor->email), config('territorial.editors', []), true), 403);
$data = Validator::make($input, [
'name' => ['required', 'string', 'max:255'], 'code' => ['required', 'string', 'max:100'],
'location_type' => ['required', 'in:'.implode(',', array_keys(self::PARENTS))],
'parent_id' => ['nullable', 'uuid', 'exists:geo_locations,id'], 'source_id' => ['required', 'uuid', 'exists:geo_sources,id'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'],
])->validate();
return DB::transaction(function () use ($actor, $data, $id) {
// Serialise hierarchy changes to prevent concurrent parent cycles and duplicate paths.
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$current = $id ? DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first() : null;
if ($id) {
abort_unless($current && DB::table('geo_territory_profiles')->where('geo_location_id', $id)->exists(), 404);
abort_unless($current->version_no === $data['version_no'], 409);
abort_unless($current->location_type === $data['location_type'], 422, 'O tipo territorial é imutável.');
}
$parentType = self::PARENTS[$data['location_type']];
$parent = empty($data['parent_id']) ? null : DB::table('geo_locations')->where('id', $data['parent_id'])->whereNull('deleted_at')->first();
abort_unless($parentType ? $parent && $parent->location_type === $parentType : ! $parent, 422, 'Hierarquia territorial incoerente.');
$duplicate = DB::table('geo_locations')->where('location_type', $data['location_type'])->whereNull('deleted_at')->when($id, fn ($q) => $q->where('id', '<>', $id))
->where(fn ($q) => $q->where('code', $data['code'])->orWhere(fn ($q) => $q->where('parent_id', $data['parent_id'] ?? null)->whereRaw('lower(trim(name))=lower(trim(?))', [$data['name']])))->exists();
abort_if($duplicate, 422, 'Código ou nome no mesmo pai já existente.');
$id ??= (string) Str::uuid();
$version = $current ? $current->version_no + 1 : 1;
DB::table('geo_locations')->updateOrInsert(['id' => $id], ['name' => $data['name'], 'code' => $data['code'],
'location_type' => $data['location_type'], 'parent_id' => $data['parent_id'] ?? null,
'version_no' => $version, 'data_classification' => 'internal', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->updateOrInsert(['geo_location_id' => $id], ['source_id' => $data['source_id'],
'valid_from' => $data['valid_from'], 'valid_to' => $data['valid_to'] ?? null, 'status' => 'draft',
'editor_id' => $actor->id, 'reviewer_id' => null, 'publisher_id' => null, 'updated_at' => now()]);
$this->snapshot($id, $version, $actor, $data['reason']);
return $id;
});
}
public function snapshot(string $id, int $version, User $actor, string $reason): void
{
$snapshot = json_encode(['location' => DB::table('geo_locations')->find($id),
'profile' => DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first()], JSON_THROW_ON_ERROR);
DB::table('geo_territory_revisions')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id,
'version_no' => $version, 'actor_id' => $actor->id, 'reason' => Crypt::encryptString($reason),
'snapshot' => Crypt::encryptString($snapshot), 'checksum' => hash('sha256', $snapshot)]);
}
}