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 $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)]); } }