---
id: MOD-CONEXOES-UNIDADEREGIONAL-DADOS
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-CONEXOES-UNIDADEREGIONAL]
fontes:
  - V2/Modules/Conexoes/UnidadeRegional/
depende_de: [CORE-DATA, CORE-BOOT, SVC-GALLERY, MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS]
consumidores: [MOD-CONEXOES-UNIDADEREGIONAL, MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS, SVC-SEARCH, SVC-SEO]
criticidade: alta
dono: Regional
---

# Conexoes/UnidadeRegional — dados e modelo de domínio

> Este documento cobre schema, models, relações, hooks e dados iniciais da unidade. Fluxos de
> tela e contratos HTTP estão em [`cms.md`](cms.md), [`portal.md`](portal.md) e [`api.md`](api.md);
> a visão geral em [`spec.md`](spec.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Persistir a unidade regional, seu vínculo com associados, sua galeria espelhada e seu token de API, propagando categoria de notícias e índice de busca a cada gravação. |
| Entrada | `save()`/`delete()` do model `UnidadeRegional`, escritas de `UnidadeRegionalEmpresa` e `UnidadeRegionalMedia`, `syncTagsFromArray()`, `generateApiToken()`/`revokeApiToken()`. |
| Saída | 3 tabelas próprias + 1 pivô de tags; categoria em `v2_news_categories`; entradas em `v2_public_search_index` (índice administrativo: `v2_cms_links_index`); colunas de token. |
| Efeitos colaterais | Hooks `creating/saving/saved/deleted`; logs `[UnidadeRegional] ...`; auditoria via `ActivityLoggerService` nos controllers (não no model). |
| Particularidade central | Não há **nenhuma FK física**: os vínculos são por convenção de coluna UUID/inteiro, e o `unique (unidade_regional_id, empresa_id)` é o único guarda de integridade do vínculo. |
| Como se integra | `Models\UnidadeRegional` (SoftDeletes, UUID manual), `RegionalIdentityService`, `PublicIndexerService`, `ConexaoTag`, `GalleryMedia`, `AssociadoInstituicao`. |
| Se removido, quebra | Categoria regional, índice de busca de regionais, tokens de API e vínculos de associados deixam de existir. |
| Superfície pública | Models e seus scopes/métodos; migrations do módulo. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

| Perfil | Ação | Resultado no banco |
|---|---|---|
| Staff nacional | criar unidade | 1 linha em `v2_unidade_regionais`, N linhas no pivô de tags; categoria `regional-<sigla>` criada; índice público atualizado |
| Staff nacional | editar unidade | campos atualizados; `metadata` regravado por inteiro; tags sincronizadas; galeria sincronizada por diferença |
| Staff nacional | arquivar | `is_active = 0`, `deleted_at` preenchido, colunas de token zeradas |
| Staff nacional | restaurar | `deleted_at = null`; `is_active` continua `0` |
| Portador de token | criar/editar/arquivar notícia | linha em `v2_news` (metadata e FK), tag da sigla, `metadata.source_markdown` |
| Portador de token | criar/vincular/arquivar associado | linha em `v2_associado_instituicoes` + linha em `v2_unidade_regional_empresas` |

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| `id` UUID v4 quando ausente e `slug` derivado de `name` | hook `creating` | `Models/UnidadeRegional.php:104-111` |
| `slug` único com sufixo numérico em colisão | hook `saving` | `Models/UnidadeRegional.php:113-129` |
| Índice público (`PublicSearchDTO`) com `module = 'unidades-regionais'` | `syncToSearch()` | `Models/UnidadeRegional.php:156-188` |
| Remoção do índice quando `is_active = false` | `syncToSearch()` (branch inicial) | `Models/UnidadeRegional.php:158-165` |
| Categoria `regional-<sigla>` criada/renomeada e presa a `regionais` | hook `saved` → `RegionalIdentityService::sync()` | `Models/UnidadeRegional.php:138-144` |
| Remoção do índice no delete | hook `deleted` | `Models/UnidadeRegional.php:147-153` |
| Pivô de tags com `is_primary` na primeira tag | `syncTagsFromArray()` | `Models/UnidadeRegional.php:82-98` |
| Mídias: apaga as ausentes e insere as novas com `sort_order = count + 1` | `syncGalleryMediaIds()` | `Controllers/UnidadeRegionalCmsController.php:397-435` |
| Token: hash/prefixo/datas | `generateApiToken()` | `Models/UnidadeRegional.php:277-291` |
| `last_used_at` em cada request autenticado | `authenticateToken()` | `Controllers/UnidadeRegionalNewsApiController.php:466-467` |

### 1.3 Contrato observável

| Entrada | Saída |
|---|---|
| `save()` de unidade com `name = 'SUCESU DF'` e sem slug | `slug = 'sucesu-df'`; se já existir, `sucesu-df-1`, `sucesu-df-2`… |
| `save()` de unidade ativa com `acronym = 'SUC-TST'` | categoria `slug = 'regional-suc-tst'`, `name = 'Regional SUC-TST'`, `parent_id` de `regionais` |
| `save()` de unidade com `is_active = false` | nenhuma linha no índice público; `PublicIndexerService::remove()` é chamado |
| `syncTagsFromArray(['Destaque', 'Sul'])` | 2 tags em `v2_conexao_tags` (`destaque`, `sul`) e 2 linhas no pivô, `is_primary` só em `destaque` |
| `syncGalleryMediaIds($u, [])` | todas as linhas com `gallery_media_id` não nulo da unidade são apagadas |
| `restore()` de unidade arquivada | `deleted_at = null`, `is_active = false`, sem reindexação automática |

### 1.4 O que este documento NÃO cobre / o módulo não faz

- Não há **FKs físicas** nas três tabelas do módulo — nenhum `onDelete` é aplicado pelo banco.
- Não há `forceDelete` em nenhum caminho do módulo.
- Não há histórico/versionamento de unidade (apenas `activities` genérico).
- `sort_order` existe na coluna e no scope `ordered()`, mas o formulário CMS não o edita.

## 2. Abordagem

### 2.1 Estratégia em uma página

```mermaid
flowchart TB
  A[Controller] -->|new/save| B[UnidadeRegional]
  B --> C{creating: id/slug}
  C --> D{saving: slug único}
  D --> E[(v2_unidade_regionais)]
  E --> F{saved}
  F --> G[PublicIndexerService index/remove]
  F --> H[RegionalIdentityService::sync]
  H --> I[(v2_news_categories)]
  E --> J{saved/deleted} --> K[PublicIndexerService]
```

Toda lógica de integridade está no model (UUID, slug, hooks). Os controllers só atribuem
campos; não chamam `sync()` explicitamente.

### 2.2 Mecânica passo a passo

1. `creating`: se `id` vazio → `v2_uuid()`; se `slug` vazio e `name` presente → `str_slug($name)`
   (`Models/UnidadeRegional.php:104-111`).
2. `saving`: recalcula o slug e testa colisão com `where('slug', $slug)->where('id','!=',$id)`
   em loop; para modelos novos omite o `id` (`:106-129`).
3. `saved`: `syncToSearch()` (sempre) e, se `acronym` não vazio, `RegionalIdentityService::sync()`
   (`:131-145`). Ambas as chamadas são `try/catch` com `Log::error`.
4. `deleted`: `PublicIndexerService::remove(get_class($model), $id)` (`:147-153`).
5. `syncToSearch`: se inativo, remove e retorna; senão monta tags `['unidade-regional','regional','sucesu',
   <state>, <acronym>]`, resumo de `metadata.resumo`, texto extraído do JSON Tiptap e cria
   `PublicSearchDTO($title, $slug, 'unidades-regionais', class, id, summary, tags, deep)` (`:156-192`).
6. `extractTextFromEditorJson`/`collectTextNodes`: percorre recursivamente `content[]` e concatena
   `text` (`:204-229`).
7. `syncTagsFromArray`: `Str::slug` por nome, `ConexaoTag::firstOrCreate`, `is_primary` apenas no
   índice 0 e `sync()` do belongsToMany (`:74-91`).
8. Controllers: `is_active` vem do request; `metadata` é reconstruído a partir do valor atual
   (`$unidade->metadata ?? []`) e sobrescrito por `resumo`, `linkedin_url`, `instagram_url`,
   `facebook_url`, `social_media`, `logo_id`, `cover_image`, `cover_image_id`
   (`Controllers/UnidadeRegionalCmsController.php:136-155`).

### 2.3 Decisões de design e por quê

- **UUID v4 gerado por helper** (`v2_uuid()`), sem default no banco — a aplicação é responsável
  por gerar o id (`Models/UnidadeRegional.php:104-107`; `V2/Core/Helpers/helpers.php:61-69`). `[C]`
- **Slug resolvido no model, não no controller** — garante unicidade em qualquer caminho (CMS,
  seeder, importação). `[C]`
- **Galeria espelhada** com `image` + `gallery_media_id` — o portal usa `galleryMedia->file_path`
  quando existe e cai em `image` quando não (`Views/portal/show.blade.php:113-115`). `[C]`
- **Token em colunas da própria unidade** (não em tabela satélite) — simplifica o lookup por
  hash, mas impede múltiplos tokens por unidade. `[C]`
- **Sem FK física** — consistente com o restante do V2, mas transfere integridade para a
  aplicação; associações órfãs são possíveis. `[C]`

### 2.4 Trechos de código pivô

```php
// V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php:17-47 — schema canônico
Schema::create('v2_unidade_regionais', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('name', 255);
    $table->string('slug', 255)->unique();
    $table->string('acronym', 10);
    $table->text('description')->nullable();
    // ... colunas de contato (site_url, state, city, address, phone, email, map_coordinates)
    $table->integer('sort_order')->default(0);
    $table->boolean('is_active')->default(true);
    $table->jsonb('metadata')->nullable();
    $table->string('api_token_hash', 64)->nullable()->comment('SHA-256 do token de API');
    $table->string('api_token_prefix', 12)->nullable()->comment('Primeiros 12 chars do token');
    $table->timestamp('api_token_expires_at')->nullable();
    $table->timestamp('api_token_last_used_at')->nullable();
    $table->timestamp('api_token_created_at')->nullable();
    $table->string('created_by', 36)->nullable();
    $table->timestamps();
    $table->softDeletes();

    $table->index('is_active', 'idx_v2_unr_active');
    $table->index('sort_order', 'idx_v2_unr_sort');
    $table->index('state', 'idx_v2_unr_state');
    $table->index('api_token_hash', 'idx_v2_unr_api_token_hash');
});
```

`api_token_hash` é indexado mas **não é unique**: dois tokens iguais seriam possíveis em teoria
(colisão de sha256 ou cópia manual), e o lookup usa `first()`.

```php
// V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:104-129 — id e slug nos hooks
static::creating(function ($model) {
    if (empty($model->id)) {
        $model->id = v2_uuid();
    }
    if (empty($model->slug) && !empty($model->name)) {
        $model->slug = str_slug($model->name);
    }
});

static::saving(function ($model) {
    $baseSlug = $model->slug ?: str_slug($model->name);
    $slug = $baseSlug;
    $counter = 1;
    if (!empty($model->id)) {
        while (static::where('slug', $slug)->where('id', '!=', $model->id)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
    } else {
        while (static::where('slug', $slug)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
    }
    $model->slug = $slug;
});
```

Renomear a unidade **não** altera o slug existente (o `saving` parte de `$model->slug`, já
preenchido), a menos que o chamador limpe o campo.

```php
// V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:156-188 — payload do índice público
public function syncToSearch()
{
    if (!$this->is_active) {
        try {
            app(PublicIndexerService::class)->remove(get_class($this), $this->id);
        } catch (\Exception $e) {
            \Log::error('[UnidadeRegional] Falha ao remover do índice público: ' . $e->getMessage());
        }
        return;
    }

    $tags = ['unidade-regional', 'regional', 'sucesu'];
    if ($this->state) $tags[] = $this->state;
    if ($this->acronym) $tags[] = $this->acronym;

    $resumo = isset($this->metadata['resumo']) ? $this->metadata['resumo'] : '';
    $resumo = trim($resumo);
    $descricaoTextual = $this->extractTextFromEditorJson($this->description);
    $searchContent = trim($resumo . ' ' . $descricaoTextual);

    $dto = new PublicSearchDTO(
        $this->name,
        $this->slug,
        'unidades-regionais',
        get_class($this),
        $this->id,
        !empty($resumo) ? $resumo : 'Regional SUCESU',
        $tags,
        !empty($searchContent) ? $searchContent : 'Regional SUCESU'
    );

    app(PublicIndexerService::class)->index($dto);
}
```

O `module` do índice é `'unidades-regionais'`, que `SearchService::publicResultUrl()` mapeia
para `/regional/{slug}` (`V2/Services/Search/Services/SearchService.php:75-76`).

```php
// V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:82-98 — tags e tag primária
public function syncTagsFromArray(array $tagNames)
{
    $tagNames = array_values(array_filter($tagNames, function ($n) { return !empty(trim($n)); }));
    $tagIds = [];

    foreach ($tagNames as $idx => $name) {
        $name = trim($name);
        $slug = \Illuminate\Support\Str::slug($name);
        $tag = \V2\Core\Models\ConexaoTag::firstOrCreate(
            ['slug' => $slug],
            ['name' => $name]
        );
        $tagIds[$tag->id] = ['is_primary' => $idx === 0];
    }

    $this->tags()->sync($tagIds);
}
```

`sync()` remove tags que não vieram no array — a lista enviada é a verdade final.

### 2.5 Algoritmos, consultas e custos

- `saving` pode executar N queries (uma por colisão de slug).
- `saved` executa 1 escrita no índice + 1–3 queries de categoria por `save()`; em cada request
  autenticado da API isso acontece de novo por causa do `last_used_at`.
- `syncTagsFromArray` executa 1 `SELECT`+possível `INSERT` por tag e 1 `sync()`.
- `syncGalleryMediaIds` executa 1 `DELETE ... WHERE NOT IN`, 1 `pluck`, 1 `SELECT` por id novo e
  1 `COUNT` + `INSERT` por id novo.
- `Media::count()` usado para `sort_order` é recalculado a cada iteração (O(n²) em n mídias).
- `UnidadeRegional::active()->ordered()` usa `idx_v2_unr_active` e `idx_v2_unr_sort`.

## 3. Particularidades

### 3.1 O que só existe aqui

- Colunas de token **na própria entidade** (hash, prefixo, 3 timestamps), sem tabela satélite.
- `metadata.resumo` como campo de vitrine (não existe coluna `resumo`, embora a validação CMS o
  trate como campo e `max:250`).
- `metadata.cover_image`/`cover_image_id` em vez de coluna dedicada.
- Espelho de galeria (`v2_unidade_regional_media`) com path duplicado.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-UNIDADEREGIONAL-D01 | `id` é UUID v4 gerado na aplicação; `$incrementing = false`, `$keyType = 'string'` | nenhum default no banco | `Models/UnidadeRegional.php:14-20,104-107` |
| RN-UNIDADEREGIONAL-D02 | `slug` é `unique` e resolvido no `saving` | colisão vira sufixo `-N` | `Migrations/2026_07_23_000001:20`; `Models/UnidadeRegional.php:113-129` |
| RN-UNIDADEREGIONAL-D03 | `acronym` tem 10 chars e **não** é unique | siglas duplicadas compartilham categoria | `Migrations/2026_07_23_000001:21` |
| RN-UNIDADEREGIONAL-D04 | `metadata` é `jsonb` com cast `array`; não há merge profundo — o controller regrava o array | chaves desconhecidas são preservadas apenas se já estavam no array lido | `Models/UnidadeRegional.php:41-48`; `Controllers/UnidadeRegionalCmsController.php:139-155` |
| RN-UNIDADEREGIONAL-D05 | O vínculo associação↔regional tem unique composto, mas **não** FK | órfãos possíveis após exclusão | `Migrations/2026_07_23_000002:25-27` |
| RN-UNIDADEREGIONAL-D06 | `v2_unidade_regional_media.gallery_media_id` é `unsignedInteger` (era `uuid`) | inteiro compatível com `v2_gallery_media.id` | `Migrations/2026_07_30_000001:38-47` |
| RN-UNIDADEREGIONAL-D07 | O vínculo tem `deleted_at`; o unique **não** considera `deleted_at` | religar um par já arquivado atualiza a linha existente em vez de inserir | `Migrations/2026_07_31_000001:15-17`; `Controllers/UnidadeRegionalEmpresaApiController.php:470-482` |
| RN-UNIDADEREGIONAL-D08 | `restore()` não reativa (`is_active` fica `false`) | unidade volta arquivada para a lista mas fora do portal/busca | `Controllers/UnidadeRegionalCmsController.php:79-93` |
| RN-UNIDADEREGIONAL-D09 | Não há reindexação após `restore()` | índice público fica sem a unidade até novo save | `Controllers/UnidadeRegionalCmsController.php:79-93` |

### 3.3 Casos-limite

- **Unidade sem `name`** (chamada direta ao model): `slug` fica nulo e o `unique` aceita vários
  nulos no PostgreSQL; o CMS impede via validação.
- **`metadata` nulo**: controller usa `?? []`; `syncToSearch` usa `isset(...['resumo'])`.
- **`description` em texto puro** (não JSON): `extractTextFromEditorJson` devolve `''`; a busca
  indexa apenas o resumo.
- **`acronym` vazio**: nenhuma categoria é sincronizada; `newsCategorySlug()` retorna `null`.
- **Restaurar e editar**: o `update` define `is_active` conforme o checkbox, permitindo reativar
  numa única operação.
- **Exclusão de associado no módulo Associados**: o vínculo permanece (sem FK/cascade) e passa
  a renderizar `nome = null` nos endpoints.
- **Mídia removida da galeria central**: `UnidadeRegionalMedia` continua apontando para o id;
  o portal cai no path `image` gravado.

### 3.4 Armadilhas de reimplementação

1. **Assumir que `metadata` é mesclado**: não é; enviar apenas `resumo` apaga `social_media` se
   o array base não for o atual.
2. **Contar com cascade**: apagar a unidade não apaga vínculos nem mídias.
3. **Usar `acronym` como chave**: não é única; use `slug`/`id`.
4. **Esperar que `sort_order` ordene a listagem CMS**: a listagem ordena por `created_at desc`.
5. **Trocar `unsignedInteger` por `uuid` em `gallery_media_id`**: reproduz o erro
   `SQLSTATE[22P02] invalid input syntax for type uuid` documentado na migration de correção.

### 3.5 Divergências e dívidas

- `MODULE.md` não menciona as tabelas de mídia nem a correção de tipo de `gallery_media_id`.
- `metadata.category` em associados é gravado pela API, mas a coluna `category` foi removida de
  `v2_associado_instituicoes` — o dado fica apenas no JSON.
- `UnidadeRegionalMedia` não usa `SoftDeletes`; o delete da galeria é físico.
- `operadores()` aponta para `App\Admin\User` (model legado) por `unidade_regional_id`.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `UnidadeRegional::scopeActive` | scope | `active()` | — | portal, `syncAll` |
| `UnidadeRegional::scopeOrdered` | scope | `ordered()` | — | portal, formulários |
| `UnidadeRegional::scopeByState` | scope | `byState(string $uf)` | — | uso externo (não usado no módulo) |
| `UnidadeRegional::hasApiToken` | método | `bool` | — | views CMS |
| `UnidadeRegional::isApiTokenValid` | método | `bool` | token presente | API |
| `UnidadeRegional::isTokenExpired` | método | `bool` | token presente | badge |
| `UnidadeRegional::generateApiToken` | método | `string` (token bruto) | — | CMS |
| `UnidadeRegional::revokeApiToken` | método | `void` | — | CMS |
| `UnidadeRegional::getTokenStatusBadge` | método | `string` HTML | — | views CMS |
| `UnidadeRegional::syncTagsFromArray` | método | `void` | — | CMS |
| `UnidadeRegionalEmpresa::empresa` | relação | `belongsTo(AssociadoInstituicao)` | — | API, portal |
| `UnidadeRegionalMedia::galleryMedia` | relação | `belongsTo(GalleryMedia)` | — | portal, CMS |

### 4.2 Contrato de UI/JS

Não se aplica a este documento (schema/model). O contrato de campos está em [`cms.md`](cms.md)
§4.2 e o de exibição em [`portal.md`](portal.md) §4.2.

### 4.3 Ordem e ciclo de vida

1. `v2_gallery_media` (SVC-GALLERY) e `v2_conexao_tags` (CORE-DATA) devem existir antes do uso.
2. `v2_unidade_regionais` deve existir antes de qualquer referência (inclusive sitemap, que faz
   `Schema::hasTable` — `V2/Services/SEO/Controllers/SeoPortalController.php:365`).
3. `v2_unidade_regional_empresas` exige `v2_associado_instituicoes`.
4. `v2_unidade_regional_media` exige `v2_gallery_media` (id inteiro).
5. As migrations usam `DB::beginTransaction()` + `SET lock_timeout = '3s'` e, no `down()`,
   renomeiam a tabela para `backup_v2_*_<timestamp>` em vez de dropar.

### 4.4 Receitas de uso

```php
// Consultar regionais ativas em ordem de exibição
$unidades = \V2\Modules\Conexoes\UnidadeRegional\Models\UnidadeRegional::active()
    ->ordered()
    ->paginate(12);
```

Resultado: coleção paginada ordenada por `sort_order ASC, name ASC`.

```php
// Sincronizar tags preservando a primeira como primária
$unidade->syncTagsFromArray(['Destaque', 'Sul', 'Sudeste']);
```

Resultado: 3 linhas no pivô; `is_primary = true` apenas em `destaque`.

```sql
-- Diagnóstico: vínculos sem associado correspondente (não há FK)
SELECT r.id, r.empresa_id
FROM v2_unidade_regional_empresas r
LEFT JOIN v2_associado_instituicoes a ON a.id = r.empresa_id
WHERE a.id IS NULL AND r.deleted_at IS NULL;
```

Resultado esperado em base íntegra: 0 linhas.

### 4.5 Anti-uso

- **`$unidade->update(['metadata' => ['resumo' => 'x']])`**: apaga redes sociais e ids de mídia.
- **`UnidadeRegional::find($id)->delete()` direto no banco**: não remove do índice se o evento
  não disparar (`DB::table(...)->delete()` não aciona hooks).
- **Sobrescrever `slug` para “corrigir” URL**: quebra links/indexação; o slug é a chave pública.

### 4.6 Custos e limites

- `metadata` é `jsonb` sem limite de tamanho no schema.
- `description` é `text` (JSON Tiptap completo).
- `api_token_prefix` limita-se a 12 chars por design.
- `sort_order` é `integer` e não é exposto no CMS.

## 5. Rotas e contratos HTTP

Não se aplica a este documento: os endpoints que escrevem neste schema estão em
[`rotas.md`](rotas.md) e [`api.md`](api.md).

## 6. Dados e modelo de domínio

### 6.1 `v2_unidade_regionais`

- **PK**: `id` (uuid). **Soft delete**: `deleted_at`. **Timestamps**: `created_at`, `updated_at`.
- **Unique**: `slug`. **Índices**: `is_active`, `sort_order`, `state`, `api_token_hash`.
- **Sem FKs físicas.**
- **Migration**: `Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php:17-47`.
- **Model**: `Models/UnidadeRegional.php` (`$table`, `$fillable` 17 campos, casts
  `is_active:bool`, `metadata:array`, três datas de token).
- **Quem escreve**: CMS (`store/update/destroy/restore`), `generateApiToken/revokeApiToken`
  (CMS), `authenticateToken` (API, apenas `last_used_at`), seeder `NacionalUnidadeRegionalSeeder`.
- **Consumidores**: portal, SEO (sitemap), Search, Noticias (relação e categoria), Associados
  (vínculos), RegionalScopeService (via `users.unidade_regional_id`).

| Coluna | Tipo | Null | Default | Cast | Observação |
|---|---|---|---|---|---|
| `id` | uuid | não | — | — | PK, `v2_uuid()` |
| `name` | varchar(255) | não | — | — | |
| `slug` | varchar(255) | não | — | — | unique, gerado |
| `acronym` | varchar(10) | não | — | — | base da categoria e do badge |
| `description` | text | sim | — | — | JSON Tiptap ou texto |
| `logo` | varchar(255) | sim | — | — | path na storage |
| `site_url` | varchar(255) | sim | — | — | |
| `state` | varchar(2) | sim | — | — | UF; filtro do CMS |
| `city` | varchar(255) | sim | — | — | |
| `address` | varchar(255) | sim | — | — | |
| `phone` | varchar(50) | sim | — | — | |
| `email` | varchar(255) | sim | — | — | |
| `map_coordinates` | varchar(255) | sim | — | — | “lat, lng” texto livre |
| `sort_order` | integer | não | 0 | — | não editável no CMS |
| `is_active` | boolean | não | true | bool | controla portal/busca |
| `metadata` | jsonb | sim | — | array | resumo, redes, mídia |
| `api_token_hash` | varchar(64) | sim | — | — | sha256 |
| `api_token_prefix` | varchar(12) | sim | — | — | exibição |
| `api_token_expires_at` | timestamp | sim | — | datetime | TTL 365 |
| `api_token_last_used_at` | timestamp | sim | — | datetime | por request |
| `api_token_created_at` | timestamp | sim | — | datetime | |
| `created_by` | varchar(36) | sim | — | — | `Sentinel::getUser()->id` |
| `created_at`/`updated_at` | timestamp | sim | — | — | |
| `deleted_at` | timestamp | sim | — | — | soft delete |

### 6.2 `v2_unidade_regional_empresas`

- **PK**: `id` (uuid). **Unique**: `(unidade_regional_id, empresa_id)`.
- **Índices**: `unidade_regional_id`, `empresa_id`. **Soft delete**: `deleted_at` (migration
  `2026_07_31_000001`). **Sem FKs.**
- **Migration**: `2026_07_23_000002:17-28`.
- **Model**: `Models/UnidadeRegionalEmpresa.php` (`$fillable` 5 campos, cast `metadata:array`,
  hook `creating` gera UUID).
- **Escrita**: `linkEmpresa()` (insert/update de `status`), `destroy()` (delete do vínculo),
  update de status em `updateAssociado()`.
- **Leitura**: `GET /associados` (API), `edit` do CMS (últimos 5), `RegionalScopeService`.
- **`metadata`**: coluna existe e é `fillable`, mas nenhum caminho do módulo a preenche.

### 6.3 `v2_unidade_regional_media`

- **PK**: `id` (uuid). **Índices**: `unidade_regional_id`, `sort_order`. **Sem soft delete, sem FKs.**
- **Migration**: `2026_07_23_000003:17-27` (coluna `gallery_media_id` como uuid) + correção em
  `2026_07_30_000001:38-47` (drop + `unsignedInteger`).
- **Model**: `Models/UnidadeRegionalMedia.php` (fillable 5 campos, `galleryMedia()`).
- **Escrita**: `syncGalleryMediaIds()` e `uploadGallery()`; **remoção**: `destroyGallery()` e a
  própria sincronização.
- **Leitura**: `edit` do CMS (galeria), `show` do portal (galeria + modal).

### 6.4 `v2_unidade_regional_tag_pivot`

- **PK composta**: `(unidade_regional_id, tag_id)`. **Extra**: `is_primary`, timestamps.
- **Índices**: `unidade_regional_id`, `tag_id`.
- **Criada por** `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:37-47`
  (unidade CORE-DATA); **escrita por** `syncTagsFromArray()`.

### 6.5 Relações do model

```php
// V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:55-80
public function empresas()   { return $this->hasMany(UnidadeRegionalEmpresa::class, 'unidade_regional_id', 'id'); }
public function operadores() { return $this->hasMany(\App\Admin\User::class, 'unidade_regional_id', 'id'); }
public function media()      { return $this->hasMany(UnidadeRegionalMedia::class, 'unidade_regional_id', 'id')->orderBy('sort_order'); }
public function tags()       { return $this->belongsToMany(\V2\Core\Models\ConexaoTag::class, 'v2_unidade_regional_tag_pivot', 'unidade_regional_id', 'tag_id')->withPivot('is_primary')->withTimestamps(); }
```

Relações inversas consumidas pelo módulo: `News::unidadeRegional()` (FK) e
`News::coverImage()` (`GalleryMedia`), `UnidadeRegionalEmpresa::empresa()`,
`UnidadeRegionalMedia::galleryMedia()`.

### 6.6 Estrutura de `metadata`

| Chave | Origem | Usada por |
|---|---|---|
| `resumo` | `resumo` do formulário CMS | card do portal, SEO description, busca |
| `linkedin_url`, `instagram_url`, `facebook_url` | inputs ocultos do builder | compatibilidade; o portal lê `social_media` |
| `social_media` | JSON do builder (`parseSocialJson`) | `getOrderedSocialMedia()` no portal |
| `logo_id` | `logo_id` da galeria | reabertura do media-input |
| `cover_image` | `file_path` da mídia | hero do portal (fundo) |
| `cover_image_id` | id da mídia | reabertura do media-input |
| `seo` | não escrito por este módulo | `SEOService::bindModel()` (`SEOService.php:29-58`) |

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Ativa: insert (is_active=true)
  Ativa --> Inativa: update is_active=false
  Inativa --> Ativa: update is_active=true
  Ativa --> Arquivada: destroy (is_active=false + deleted_at)
  Inativa --> Arquivada: destroy
  Arquivada --> Inativa: restore (deleted_at=null; is_active continua false)
```

- **FL-UNIDADEREGIONAL-D01 (insert)**: `creating` → `saving` → `INSERT` → `saved` (índice +
  categoria) → retorno do controller.
- **FL-UNIDADEREGIONAL-D02 (update)**: leitura do `metadata` atual → sobrescrita parcial →
  `saving` (slug) → `UPDATE` → `saved` → `syncTagsFromArray` → `syncGalleryMediaIds`.
- **FL-UNIDADEREGIONAL-D03 (delete)**: `is_active=false` + revogação de token → `save()` →
  `delete()` (soft) → `deleted` remove do índice.
- **FL-UNIDADEREGIONAL-D04 (restore)**: `onlyTrashed()->findOrFail` → `restore()` → cache limpo.

## 8. UI CMS

Não se aplica: telas e campos em [`cms.md`](cms.md).

## 9. UI Portal

Não se aplica: exibição do schema em [`portal.md`](portal.md).

## 10. Configuração, permissões e flags

| Chave | Default | Efeito neste documento | Fonte |
|---|---|---|---|
| `filesystems.media_disk` | `public` | disco das mídias espelhadas | `V2/Services/Storage/StorageService.php:35-36` |
| `cache.prefix` | `laravel` | usado apenas pelos controllers ao limpar cache | `Controllers/UnidadeRegionalCmsController.php:527-529` |
| `v2.unidade_regional.api_token_ttl_days` | inexistente → 365 | TTL gravado em `api_token_expires_at` | `Models/UnidadeRegional.php:283-285` |

Escrita no schema exige RBAC `UnidadeRegional: write` no CMS; a API não usa o RBAC e sim o
token da unidade.

## 11. Observabilidade, erros e limites

- Logs de hook: `[UnidadeRegional] Falha ao sincronizar busca`, `Falha ao prover identidade
  regional`, `Falha ao remover do índice público` (`Models/UnidadeRegional.php:135,143,162`).
- Logs de migration: `[Migration] Tabela '...' criada com sucesso.` / `ERRO AO CRIAR ...` /
  `Tabela '...' preservada como 'backup_...'`.
- Não há auditoria de alteração de schema.
- Erros de integridade: `unique` de slug e do par unidade/empresa; nenhum `ON DELETE` do banco.

## 12. Seeds e dados iniciais

| Seeder | Tabela | Registro | Idempotência | Fonte |
|---|---|---|---|---|
| `NacionalUnidadeRegionalSeeder` | `v2_unidade_regionais` | `acronym = 'NAC'`, `name = 'Nacional'`, `slug = 'nacional'`, `state = 'BR'`, `is_active = true`, `metadata.resumo = 'Abrangência Nacional SUCESU'` | `firstOrCreate(['acronym' => 'NAC'])` | `database/seeds/NacionalUnidadeRegionalSeeder.php:9-23` |
| `CreateV2ConexaoTagsTable` | `v2_conexao_tags` | tag `Geral` (`slug = 'geral'`, `is_reserved = true`) | insert direto (não idempotente) | `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:49-55` |

O seeder `NacionalUnidadeRegionalSeeder` é o **primeiro** de `DatabaseSeeder::run()` e cria a
unidade `NAC` que representa a nacional.

## 13. Lacunas

| ID | Lacuna | Onde procurar | Impacto |
|---|---|---|---|
| LG-UNIDADEREGIONAL-D01 | Não há `unique`/validação em `acronym` | migration + `RegionalIdentityService` | categorias compartilhadas |
| LG-UNIDADEREGIONAL-D02 | `metadata` sem contrato formal de chaves | controllers/views | risco de perda em reimplementação |
| LG-UNIDADEREGIONAL-D03 | `v2_unidade_regional_empresas.metadata` nunca é escrita — propósito desconhecido | busca por `UnidadeRegionalEmpresa` | coluna morta |
| LG-UNIDADEREGIONAL-D04 | `sort_order` não é editável; não há seed de ordem para units além da nacional | `ordered()` | ordenação depende de 0 |
| LG-UNIDADEREGIONAL-D05 | Falta saber se o ambiente de produção tem índices adicionais fora das migrations | `\d v2_unidade_regionais` em produção | divergência schema/código |

## 14. Checklist de paridade

- [ ] `PC-UNIDADEREGIONAL-D01` — inserir unidade sem id → UUID v4 gravado (`Models/UnidadeRegional.php:104-107`).
- [ ] `PC-UNIDADEREGIONAL-D02` — inserir duas unidades com o mesmo `name` → slugs `x` e `x-1` (`:106-129`).
- [ ] `PC-UNIDADEREGIONAL-D03` — salvar com sigla → categoria com `parent_id` de `regionais` (`RegionalIdentityService.php:33-61`).
- [ ] `PC-UNIDADEREGIONAL-D04` — desativar → linha removida do índice público (`Models/UnidadeRegional.php:158-165`).
- [ ] `PC-UNIDADEREGIONAL-D05` — deletar → `deleted_at` e remoção do índice (`:147-153`).
- [ ] `PC-UNIDADEREGIONAL-D06` — `syncTagsFromArray(['A','B'])` → `is_primary` só em A (`:74-91`).
- [ ] `PC-UNIDADEREGIONAL-D07` — `syncGalleryMediaIds($u, [])` → apaga mídias com id (`Controllers/UnidadeRegionalCmsController.php:405-408`).
- [ ] `PC-UNIDADEREGIONAL-D08` — inserir vínculo duplicado unidade+empresa → viola `idx_v2_unr_emp_unique` (`Migrations/2026_07_23_000002:27`).
- [ ] `PC-UNIDADEREGIONAL-D09` — inserir `gallery_media_id` inteiro → aceito (coluna `unsignedInteger`) (`Migrations/2026_07_30_000001:38-47`).
- [ ] `PC-UNIDADEREGIONAL-D10` — remover tag da lista no update → linha do pivô desaparece (`Models/UnidadeRegional.php:90`).

## 15. Evidências consultadas

- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php:13-82`
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000002_create_v2_unidade_regional_empresas_table.php:13-62`
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000003_create_v2_unidade_regional_media_table.php:13-60`
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_30_000001_fix_gallery_media_id_type.php:30-75`
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_31_000001_add_soft_deletes_to_v2_unidade_regional_empresas_table.php:10-48`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:1-326`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegionalEmpresa.php:1-57`
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegionalMedia.php:1-48`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalCmsController.php:79-93,100-171,267-288,397-435`
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalEmpresaApiController.php:50-52,185-225,468-496`
- `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:14-120`
- `V2/Core/Models/ConexaoTag.php:8-45`
- `V2/Core/Migrations/2026_08_18_000001_create_v2_conexao_tags_table.php:18-55`
- `V2/Services/Search/DTOs/PublicSearchDTO.php:9-52`; `V2/Services/Search/Services/SearchService.php:74-78`
- `V2/Services/Storage/StorageService.php:30-60`
- `V2/Services/SEO/Services/SEOService.php:29-58`
- `database/seeds/NacionalUnidadeRegionalSeeder.php:9-23`; `database/seeds/DatabaseSeeder.php:7-13`
- `V2/Modules/Conexoes/Associados/Migrations/2024_01_01_000020_drop_category_from_associado_instituicoes.php:13-37`
