---
id: CORE-DATA-REGIONAL
tipo: arquitetura
onda: 1
status: concluido
unidades: [CORE-DATA]
fontes:
  - V2/Core/Services/RegionalIdentity/RegionalIdentityService.php
  - V2/Core/Services/RegionalScopeService.php
depende_de: [CORE-BOOT, CORE-DATA]
consumidores: [MOD-CONEXOES-UNIDADEREGIONAL, MOD-PUBLICACOES-NOTICIAS, MOD-CONEXOES-ASSOCIADOS, CORE-AUTH, CORE-CMS]
criticidade: alta
dono: CoreData
---

# Arquitetura: tenancy regional (identidade de categoria + escopo de dados)

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | cada Unidade Regional ativa ganha uma subcategoria de notícias `regional-<sigla>` sob `Regionais`; usuários com `unidade_regional_id` só listam/alteram registros da própria regional |
| Entrada | `UnidadeRegional::save()` (hook `saved`), chamadas explícitas a `syncAll()`/`newsCategoryFor()`, `Sentinel` + coluna `users.unidade_regional_id` |
| Saída | linha em `v2_news_categories`; `Builder` filtrado por `byUnidadeRegional()`; `abort(403, 'Acesso restrito aos dados da sua Unidade Regional.')`; logs estruturados |
| Efeitos colaterais | `Log::debug` por lista escopada; `ActivityLogger::log(..., 'regional_access_denied', ...)` best-effort; `Log::error` quando o sync de categoria falha (engolido) |
| Particularidade central | o slug é derivado da **sigla** (`acronym`), não do nome/slug da unidade; o inverso é uma consulta case-insensitive por sigla, e não há unicidade de sigla no schema |
| Como se integra | `app(RegionalIdentityService::class)`; contrato `V2\Core\Contracts\RegionalOwnable`; helpers `v2_is_regional_operator()` / `v2_regional_id()`; middleware `RegionalRestrictMiddleware` |
| Se removido, quebra | criação automática de categorias regionais no CMS/API de notícias, filtro de listagem de Notícias/Associados, bloqueio de rotas nacionais e auditoria de acesso negado |
| Superfície pública | `RegionalIdentityService`: `newsCategoryParent`, `newsCategorySlug`, `newsCategoryFor`, `findNewsCategory`, `unidadeForCategorySlug`, `sync`, `syncAll`, `isRegionalSlug` (estático); `RegionalScopeService`: `isRegionalOperator`, `regionalId`, `applyScope`, `owns`, `authorize` |

## 1. Efeito

### 1.1 Identidade regional em Notícias

1. Ao salvar uma `UnidadeRegional` com `acronym` não vazio, o hook `saved` chama `RegionalIdentityService::sync($model)` — `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:131-145`. O resultado é uma linha em `v2_news_categories` com `slug = 'regional-' . strtolower(trim(acronym))` (`V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:22-31`), `name = 'Regional ' . strtoupper(acronym)` (`:42`) e `parent_id` = id da categoria-mãe `regionais` (`:41,50`).
2. A categoria-mãe é criada sob demanda por `firstOrCreate(['slug' => 'regionais'], ['name' => 'Regionais', 'is_system' => true, 'sort_order' => 2])` — `:14-20`. Se já existir, **nada** é corrigido nela (nome, `is_system` e `sort_order` não são atualizados).
3. Se a subcategoria existe mas com nome ou pai divergente, ela é renomeada/reparentada e salva (`:54-58`). Se existe correta, **nenhuma** query de escrita é emitida — o sync é idempotente e barato no caso comum (1 `SELECT` de pai, 1 `SELECT` de filho).
4. Se o `acronym` estiver vazio, `newsCategorySlug()` devolve `null` e **nenhuma** categoria é criada, sem erro (`:22-28,37-39`); o hook só chama o serviço quando `!empty($model->acronym)` (`UnidadeRegional.php:138`).

### 1.2 Escopo de dados

Para um usuário resolvido via `Sentinel::check()`:

- **Operador regional** = tem `unidade_regional_id` preenchido e **não** é suporte (`config('v2.support.email')`) nem `super-admin` — `V2/Core/Services/RegionalScopeService.php:38-51,169-180`.
- Em listagens, `applyScope($query)` devolve `$query->byUnidadeRegional($regionalId)` **somente** quando o usuário é operador e o model implementa `V2\Core\Contracts\RegionalOwnable` (`:78-103`). Caso contrário devolve a query intacta.
- Em acesso a registro, `owns($model)` devolve `true` para não-operador ou model não escopável; para operador chama `belongsToUnidadeRegional($regionalId)` (`:113-124`).
- `authorize($model, $user, $action)` chama `owns()`; se `false`, registra `regional_access_denied` e executa `abort(403, 'Acesso restrito aos dados da sua Unidade Regional.')` (`:134-142`).
- O middleware `RegionalRestrictMiddleware` complementa o escopo bloqueando operadores em rotas nacionais: AJAX/JSON recebe `response('Forbidden. Ação restrita à administração nacional.', 403)`; navegação normal é redirecionada para `v2.cms.dashboard` com flash `error = 'Acesso negado. Esta ação é restrita à administração nacional.'` (`V2/Core/Middleware/RegionalRestrictMiddleware.php:32-45`).

### 1.3 O que **não** acontece

- Não há `globalScope`: model `RegionalOwnable` **não** filtra sozinho. Esquecer `applyScope` numa listagem vaza todos os registros (fail-open).
- Não há exclusão nem renomeação automática de categorias quando a unidade é desativada, excluída ou muda de sigla. `syncAll()` só percorre `UnidadeRegional::active()` (`:112`) e nunca apaga.
- Não há normalização de `acronym`: o modelo não faz uppercase/trim no `saving`; apenas o slug usa `trim()` + `strtolower()`.
- `regionalId()` **não** aplica o bypass: um suporte/super-admin com `unidade_regional_id` preenchido recebe o id (`:57-68`), embora `isRegionalOperator()` devolva `false` — ver RN-CORE-DATA-11.
- O escopo não verifica `status` do vínculo em `v2_unidade_regional_empresas`: vínculo `status='inactive'` continua concedendo acesso (ver §3).

## 2. Abordagem

### 2.1 Mecânica do sync de categoria (passos reproduzíveis)

**Gatilho.** `UnidadeRegional::saved` (dispara em `create`, `update`, `save()` de qualquer atributo, inclusive toggle de `is_active` e edição de metadata) — `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:131-145`. O corpo é envolvido em `try/catch (\Exception)` e a falha vira apenas `Log::error('[UnidadeRegional] Falha ao prover identidade regional: ' . $e->getMessage())`. O sync **nunca** impede o salvamento da unidade.

Passos (com as queries reais):

1. **Guard**: `if (!empty($model->acronym))` — sem sigla, para aqui.
2. **Slug**: `trim((string)$regional->acronym)`; se vazio → `null`; senão `'regional-' . strtolower($acronym)` (`RegionalIdentityService.php:22-31`). Ex.: `PE` → `regional-pe`; `pe` → `regional-pe`.
3. **Pai**: `NewsCategory::firstOrCreate(slug='regionais')` (`:14-20`). `NewsCategory::saving` roda antes do insert e pode sufixar o slug se já existir (`V2/Modules/Publicacoes/Noticias/Models/NewsCategory.php:41-54`) — no caminho normal não há colisão.
4. **Consulta do filho**: `NewsCategory::where('slug', $slug)->first()` (`:44`).
5. **Criação** (filho ausente): `NewsCategory::create(['name' => 'Regional '.strtoupper($acronym), 'slug' => $slug, 'parent_id' => $parent->id])` (`:46-51`). `id` é gerado pelo hook `creating` (`NewsCategory.php:32-39`); `is_system` fica no default `false` e `sort_order` no default `0`.
6. **Reparo** (filho presente e divergente): se `parent_id !== $parent->id` **ou** `name !== 'Regional '.strtoupper($acronym)`, grava `parent_id` e `name` e chama `save()` (`:54-58`). O `saving` do model recalcula o slug (sem mudança, pois já é o mesmo).
7. **Retorno**: a categoria (nova ou existente) ou `null` quando não há sigla.

**Efeitos colaterais do passo 5/6:** uma linha nova em `v2_news_categories` (ou um `UPDATE` com `updated_at` novo); nenhum índice de busca é atualizado, nenhum cache é invalidado, nenhum evento é disparado além dos hooks do próprio model.

**Custo:** 2–3 queries por `save()` de unidade (pai + filho + eventual escrita). `syncAll()` repete esse custo para cada unidade ativa: `N` unidades → `2N` a `3N` queries, sem eager loading e sem batch.

```mermaid
flowchart TD
    A[saved UnidadeRegional] --> B{acronym vazio?}
    B -- sim --> Z[fim, sem efeito]
    B -- não --> C[slug = regional-<sigla minúscula>]
    C --> D[firstOrCreate categoria regionais]
    D --> E[SELECT categoria por slug]
    E -- ausente --> F[create name='Regional <SIGLA>' parent=regionais]
    E -- presente --> G{parent_id ou name divergem?}
    G -- sim --> H[save parent_id + name]
    G -- não --> I[retorna sem escrever]
    F --> J[categoria regional disponível no CMS/API]
    H --> J
```

### 2.2 Mecânica do escopo (decisão em cascata)

```mermaid
flowchart TD
    A[applyScope query, user?] --> B[resolveUser: user ou Sentinel]
    B --> C{user existe?}
    C -- não --> Q[query intacta]
    C -- sim --> D{email = v2.support.email ou inRole super-admin?}
    D -- sim --> Q
    D -- não --> E{unidade_regional_id preenchido?}
    E -- não --> Q
    E -- sim --> F{model instanceof RegionalOwnable?}
    F -- não --> Q
    F -- sim --> G[Log::debug regional.scope]
    G --> H["query->byUnidadeRegional(regionalId)"]
```

`owns()` usa a mesma cascata, mas o fallback é `true` e a decisão final é `belongsToUnidadeRegional($id)`; `authorize()` transforma `false` em `abort(403)`.

### 2.3 Trechos de código pivô

```php
// V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:14-31 — o pai é
// firstOrCreate (nunca corrige), e o slug do filho deriva SÓ da sigla.
public function newsCategoryParent(): NewsCategory
{
    return NewsCategory::firstOrCreate(
        ['slug' => self::PARENT_SLUG],
        ['name' => self::PARENT_NAME, 'is_system' => true, 'sort_order' => 2]
    );
}

public function newsCategorySlug(UnidadeRegional $regional): ?string
{
    $acronym = trim((string) $regional->acronym);

    if ($acronym === '') {
        return null;
    }

    return 'regional-' . strtolower($acronym);
}
```

**O que mostra.** `PARENT_SLUG = 'regionais'`, `PARENT_NAME = 'Regionais'`, `REGIONAL_PREFIX = 'regional-'` (`:10-12`) são o contrato inteiro de nomenclatura. Renomear a unidade não muda o slug; mudar a sigla muda (e deixa órfã a categoria antiga).

```php
// V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:41-60 — criação e
// reparo da subcategoria. O create NÃO define is_system nem sort_order.
$parent = $this->newsCategoryParent();
$name = 'Regional ' . strtoupper($regional->acronym);

$category = NewsCategory::where('slug', $slug)->first();

if (!$category) {
    return NewsCategory::create([
        'name' => $name,
        'slug' => $slug,
        'parent_id' => $parent->id,
    ]);
}

if ($category->parent_id !== $parent->id || $category->name !== $name) {
    $category->parent_id = $parent->id;
    $category->name = $name;
    $category->save();
}

return $category;
```

**O que mostra.** O efeito é *provisionar + convergir*: a categoria existente é forçada de volta ao nome/pai canônicos, mas o `slug` nunca é alterado. A comparação `!==` é estrita entre strings; `parent_id` nulo (categoria raiz) sempre diverge e provoca `UPDATE`.

```php
// V2/Modules/Publicacoes/Noticias/Models/NewsCategory.php:41-54 — o hook saving
// sufixa o slug em caso de colisão; é ele que evita erro de unique em corrida.
static::saving(function ($model) {
    $baseSlug = $model->slug ?: str_slug($model->name);
    $slug = $baseSlug;
    $counter = 1;
    $query = static::where('slug', $slug);
    if (!empty($model->id)) {
        $query->where('id', '!=', $model->id);
    }
    while ($query->exists()) {
        $slug = $baseSlug . '-' . $counter;
        $counter++;
    }
    $model->slug = $slug;
});
```

**O que mostra.** Duas unidades com a mesma sigla **não** geram exceção: a segunda criação vira `regional-<sigla>-1`. O `newsCategoryFor` original continua encontrando a primeira; a segunda categoria fica órfã no CMS. É a razão pela qual o sistema tolera sigla duplicada (não há unique em `acronym`).

```php
// V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:79-94 — o inverso:
// prefixo obrigatório, sigla case-insensitive e nada mais.
public function unidadeForCategorySlug(string $slug): ?UnidadeRegional
{
    $slug = strtolower(trim($slug));

    if (strpos($slug, self::REGIONAL_PREFIX) !== 0) {
        return null;
    }

    $acronym = substr($slug, strlen(self::REGIONAL_PREFIX));

    if ($acronym === '') {
        return null;
    }

    return UnidadeRegional::whereRaw('LOWER(acronym) = ?', [$acronym])->first();
}
```

**O que mostra.** O inverso **não** verifica se a categoria existe: dado qualquer `regional-x`, devolve a unidade cuja sigla seja `x`. `LOWER(acronym)` impede uso de índice comum em `acronym`. Sem `acronym` único, `first()` escolhe uma linha arbitrária (na prática, a de menor posição física).

```php
// V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:108-122 — sync em lote:
// só unidades ativas; conta processadas, não criadas.
public function syncAll(): int
{
    $synced = 0;

    foreach (UnidadeRegional::active()->get() as $regional) {
        if ($this->newsCategorySlug($regional) === null) {
            continue;
        }

        $this->newsCategoryFor($regional);
        $synced++;
    }

    return $synced;
}
```

**O que mostra.** `active()` é `where('is_active', true)` (`V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:235-238`). O retorno conta unidades com sigla, mesmo quando nenhuma escrita ocorreu. É chamado em `create()` e `edit()` do CMS de Notícias (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:192,366`) — ou seja, **cada abertura de formulário** recalcula todas as unidades ativas.

```php
// V2/Core/Services/RegionalScopeService.php:84-102 — só filtra se o model implementar
// o contrato; a query sem escopo é devolvida intacta (fail-open).
$model = $query->getModel();

if (!($model instanceof RegionalOwnable)) {
    return $query;
}

$regionalId = $this->regionalId($user);

\Log::debug('[V2 > RegionalScope] Listagem escopada por regional.', [
    'module' => 'regional.scope',
    'action' => 'apply_scope',
    'id' => get_class($model),
    'user' => $this->userEmail($user),
    'unidade_regional_id' => $regionalId,
    'ts' => now()->toIso8601String(),
    'duration_ms' => $this->durationMs(),
]);

return $query->byUnidadeRegional($regionalId);
```

**O que mostra.** O log é `debug` e usa a chave `id` para a **classe** do model (não um id de registro). O escopo real é delegado ao model via `scopeByUnidadeRegional` — o Core não sabe qual coluna/pivô usar.

```php
// V2/Core/Services/RegionalScopeService.php:113-142 — pertença e autorização.
public function owns($model, $user = null): bool
{
    if (!$this->isRegionalOperator($user)) {
        return true;
    }

    if (!($model instanceof RegionalOwnable)) {
        return true;
    }

    return $model->belongsToUnidadeRegional($this->regionalId($user));
}

public function authorize($model, $user = null, $action = 'access')
{
    if ($this->owns($model, $user)) {
        return;
    }

    $this->logDenied($model, $user, $action);
    abort(403, 'Acesso restrito aos dados da sua Unidade Regional.');
}
```

**O que mostra.** `authorize()` não retorna valor útil e não devolve JSON: usa `abort()`, então o formato do corpo é o handler de exceções do Laravel, **não** o envelope `ApiResponse`. `$action` só existe para auditoria (`'edit'`, `'destroy'`, `'gallery_upload'` etc.).

### 2.4 Decisões e restrições

- **Sigla como chave de identidade** (`regional-<acronym>`) em vez de id da unidade: o slug é legível e estável em URLs, mas cria acoplamento com um campo mutável e não único. A decisão contrária (usar o UUID da unidade) eliminaria colisões e órfãos.
- **Sync no model, não em observer dedicado**: menos arquivos, mas acopla o módulo UnidadeRegional ao Core e faz qualquer `save()` pagar o custo.
- **Erro de sync engolido**: disponibilidade > consistência. Uma falha deixa a unidade sem categoria silenciosamente; o reparo é chamar `syncAll()` abrindo o CMS de Notícias.
- **Fail-open no escopo**: o Core só restringe o que declara `RegionalOwnable`; a segurança depende de cada módulo lembrar de chamar `applyScope`/`authorize`.
- **Bypass por e-mail/role** é avaliado a cada chamada, não no login: mudar `config('v2.support.email')` muda o comportamento sem novo login.

## 3. Particularidades

### 3.1 Invariantes e regras

| ID | Regra | Evidência |
|---|---|---|
| RN-CORE-DATA-09 | O slug regional é `regional-` + sigla em minúsculas; o nome é `Regional ` + sigla em maiúsculas. Nenhum dos dois usa `name`/`slug` da unidade | `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php:30,42` |
| RN-CORE-DATA-10 | A categoria-mãe é `regionais` com `is_system=true, sort_order=2`; a filha nasce com `is_system=false, sort_order=0`, logo é excluível pelo CMS | `:14-20,46-51`; `V2/Modules/Publicacoes/Noticias/Views/cms/categories.blade.php:75` |
| RN-CORE-DATA-11 | `isRegionalOperator()` aplica bypass de suporte/super-admin; `regionalId()` **não** aplica | `V2/Core/Services/RegionalScopeService.php:46-50` vs. `:60-67` |
| RN-CORE-DATA-12 | Sem `unidade_regional_id`, nenhuma restrição; com ele, **qualquer** usuário é operador (não importa o papel Sentinel) | `:50` |
| RN-CORE-DATA-13 | Só models `RegionalOwnable` são escopáveis; os demais passam intactos | `:86-88,119-121` |
| RN-CORE-DATA-14 | Acesso negado é auditado com descrição `regional_access_denied` e propriedades `module/action/id/unidade_regional_id/user_email/ts/duration_ms` | `:194-210` |
| RN-CORE-DATA-15 | Falha da auditoria não bloqueia o 403: `catch` → `Log::warning('[RegionalScopeService] Falha ao auditar acesso negado: '…)` | `:205-208` |
| RN-CORE-DATA-16 | `syncAll()` conta unidades ativas com sigla, não categorias criadas | `:108-122` |

### 3.2 Casos-limite

- **Sigla duplicada.** `acronym` não é `unique` (`V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php:21`). Duas unidades `PE` → uma categoria `regional-pe` e uma `regional-pe-1` (sufixo do hook `saving`). O inverso sempre devolve a primeira unidade encontrada; a segunda nunca é alcançável por categoria.
- **Sigla com espaços internos** (`"SU CESU"`) produz slug com espaço; a URL de categoria e o filtro do portal não normalizam isso. A sigla com espaços nas bordas funciona no sentido direto (há `trim`) mas **falha no inverso** (o `substr` compara contra `LOWER(acronym)` sem `trim`).
- **Mudança de sigla.** `PE` → `PE2`: cria `regional-pe2`, deixa `regional-pe` órfã, e o `NewsCategory` antigo continua associado a notícias (`v2_news.category_id`). Não há migração de conteúdo.
- **Desativação da unidade.** `is_active=false` impede novo sync (`scopeActive`), mas a categoria permanece; `syncAll` não a remove.
- **Categoria excluída manualmente.** Se for hard delete, o próximo `save()` ou `syncAll()` recria. Se for soft delete, **não** funciona: `NewsCategory` não usa `SoftDeletes` (`V2/Modules/Publicacoes/Noticias/Models/NewsCategory.php:7-9`), embora a tabela tenha `deleted_at`; a linha soft-deletada continua aparecendo no `where('slug', …)` e o sistema a considera existente.
- **Unidade criada sem sigla** e sigla preenchida depois: o `save()` seguinte cria a categoria (o hook roda em qualquer save).
- **Concorrência no sync.** Dois saves simultâneos da mesma unidade podem ambos não encontrar o filho; o segundo insert é sufixado pelo hook, gerando `regional-xx-1`. Não há lock nem `firstOrCreate` para o filho.
- **Operador regional com registro sem regional.** `owns()` devolve `false` quando `unidade_regional_id` do registro é `null` → 403. Notícias antigas sem vínculo ficam invisíveis para operadores.
- **Associado com vínculo inativo.** `scopeByUnidadeRegional` usa `whereHas('unidadesRegionais')` sem filtrar `status` (`V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php:91-96`), então `status='inactive'` em `v2_unidade_regional_empresas` continua concedendo acesso. `[I]` nem o `deleted_at` do pivô é considerado: `UnidadeRegionalEmpresa` usa `SoftDeletes` (`V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegionalEmpresa.php:10-12`), mas a relação `belongsToMany`/`whereHas` não aplica esse escopo às linhas do pivô.
- **Notícia criada por API da regional.** O vínculo pode existir só em `metadata->source_unidade_regional_id`; `News::scopeByRegional` cobre os dois casos com `orWhere` (`V2/Modules/Publicacoes/Noticias/Models/News.php:439-445`) e `belongsToUnidadeRegional` replica a regra (`:465-476`).
- **`getUnidadeRegionalId()` de associado** devolve `unidadesRegionais()->first()` sem `orderBy` (`AssociadoInstituicao.php:120-129`): com duas regionais vinculadas, o "principal" é indeterminado.

### 3.3 Surpresas e divergências

- **Código morto documentado como vivo.** `isRegionalSlug()` é anunciado como "única fonte de verdade para a detecção de regional em views/JS" (`RegionalIdentityService.php:124-136`) e como usado por views no `MODULE.md` (`V2/Modules/Conexoes/UnidadeRegional/MODULE.md:76`) e em `.seeds/issues.jsonl` (issue `sucesu-be49`), mas **não há nenhum call site** no código atual. O blade `_editor-content.blade.php` não emite `data-is-regional`. Reimplementar a view "como documentado" recria uma dependência que o sistema não tem.
- **`isRegionalSlug` aceita `'regional'` puro** (`:134`), que não corresponde a nenhuma categoria real, e aceita qualquer prefixo `regional-` — inclusive `regional-pe-1`.
- **Comentário do CMS mente sobre o slug.** `NoticiasCmsController` descreve o exemplo como "Regionais > PE, slug `regionais-pe`" (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:197-199,921-922`), mas o serviço gera `regional-pe`. O fallback configurável usa `regionais` (`config('v2.regional_operator.forced_news_category_slug', 'regionais')`, `:947-952`).
- **`syncAll()` em `create()`/`edit()`** faz trabalho de escrita dentro de GET de formulário; com as 6 unidades semeadas (`DF, BA, PE, PA, RS, PR`, `V2/Seeds/Content/DemoContentSeeder.php:80-129`) são 12–18 queries por abertura; em produção cresce O(N), sem reuso entre requisições.
- **`RegionalScopeService` não é singleton** (sem binding no provider); cada injeção cria instância e um novo `ActivityLoggerService`. `PermissionService`, por contraste, é singleton (`V2/Core/Providers/V2ServiceProvider.php:353-360`).
- **A trilha de auditoria usa `request()`**: fora de HTTP (comando/queue), `request()->ip()`/`userAgent()` podem ser nulos; `ActivityLoggerService::log` não protege (`V2/Services/ActivityLogger/ActivityLoggerService.php:27-31`).

### 3.4 O que **não** é responsabilidade do Core

| Não é do Core | Dono |
|---|---|
| CRUD, validação e slug da Unidade Regional; upload de logo; paginação do CMS | `MOD-CONEXOES-UNIDADEREGIONAL` |
| Criar usuário, atribuir papel `regional`/`super-admin`, checar permissão de módulo | `CORE-AUTH` / `MOD-INFRAESTRUTURA-GESTAOPERMISSOES` |
| Forçar `category_id` no POST e travar o campo no formulário | `MOD-PUBLICACOES-NOTICIAS` (`NoticiasCmsController::forceRegionalFields`) |
| Criar tag de notícia a partir da sigla (`NewsTag`) e vincular tags à notícia | `MOD-PUBLICACOES-NOTICIAS` / `MOD-SUPPORT-NOTICIASIMPORT` |
| Definir o vínculo associado↔regional (`v2_unidade_regional_empresas`) e seu `status` | `MOD-CONEXOES-ASSOCIADOS` |
| Filtrar o portal público por regional e decidir a URL/categoria exibida | `MOD-PUBLICACOES-NOTICIAS` (portal) |
| Remover categorias órfãs quando a unidade muda de sigla ou é desativada | ninguém — lacuna funcional |
| Invalidar cache de páginas ao criar/renomear categoria | ninguém — `newsCategoryFor` não chama `clearPageCache()` |
| Decidir que a regional "nacional" é `NAC` | `MOD-PUBLICACOES-NOTICIAS` (fallback `UnidadeRegional::where('acronym','NAC')` em `NoticiasCmsController.php:915`) |

## 4. Integrabilidade

### 4.1 Contrato para UnidadeRegional (dono do dado)

- Deve ter `acronym` (string ≤10), `name`, `slug`, `is_active` e escopo `active()`; o Core depende apenas desses campos (`UnidadeRegional.php:17-47,235-238`).
- Ao implementar o model, **não** replique o slug da categoria: chame o serviço (§4.4, receita 1). O hook `saved` é a única integração necessária para provisionar.
- `acronym` deve ser tratado como chave natural estável. Se a reimplementação quiser unicidade, crie o índice — mas ele **não existe** no legado e adicioná-lo muda o comportamento em produção (unidades duplicadas passariam a falhar no save).

### 4.2 Contrato para Notícias

- `News` implementa `RegionalOwnable`: `scopeByUnidadeRegional` delega a `scopeByRegional` (FK **ou** `metadata->source_unidade_regional_id`), `belongsToUnidadeRegional` replica a regra, `getUnidadeRegionalId` devolve a FK (`V2/Modules/Publicacoes/Noticias/Models/News.php:439-486`).
- O CMS força categoria e unidade para operador via `forceRegionalFields()` (`NoticiasCmsController.php:883-892`), ignorando `category_id` do POST. A API da regional ignora `category_id` e deriva do token (`V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:555-570`).
- A categoria do operador é `newsCategoryFor($unidade)` com fallback para a categoria `regionais` (`NoticiasCmsController.php:936-949`).

### 4.3 Contrato para Associados

- `AssociadoInstituicao` é `RegionalOwnable` via pivô `v2_unidade_regional_empresas` (`AssociadoInstituicao.php:74-96`).
- O vínculo é criado por outro fluxo (`InstituicaoCmsController` ao salvar o associado, `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:382-390`, e a API da regional), não por este serviço. O Core apenas lê o pivô.
- `v2_unidade_regional_tag_pivot` **não** é usado para escopo — é o catálogo de tags (CORE-DATA), relação `UnidadeRegional::tags` (`V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:77`).

### 4.4 Receitas de uso

```php
// 1. Provisionar/renomear a categoria de uma unidade (idempotente)
$regional = UnidadeRegional::findOrFail($id);
$category = app(RegionalIdentityService::class)->sync($regional);
// 'PE' → categoria slug 'regional-pe', name 'Regional PE', parent 'regionais'
```

```php
// 2. Listar somente o que o operador pode ver (CMS)
$query = News::query()->with('category');
$query = $this->regionalScope->applyScope($query);
$news = $query->paginate(20);
```

```php
// 3. Autorizar acesso a um registro antes de editar/destruir
$news = News::findOrFail($id);
$this->regionalScope->authorize($news, null, 'edit'); // abort(403) se não pertencer
```

```php
// 4. Derivar a unidade a partir da categoria (formulário/API)
$unidade = app(RegionalIdentityService::class)->unidadeForCategorySlug('regional-pe');
$category = $unidade
    ? app(RegionalIdentityService::class)->newsCategoryFor($unidade)
    : NewsCategory::where('slug', 'regionais')->first();
```

```php
// 5. Decidir em Blade/JS sem duplicar a regra
$isRegional = v2_is_regional_operator();
$regionalId = v2_regional_id();
```

### 4.5 Anti-uso

- **Chamar `applyScope` e depois adicionar `orWhere` no topo da query**: o `orWhere` escapa do agrupamento e reabre o conjunto inteiro. Envolva em closure ou aplique o escopo por último, como faz `News::scopeByRegional` (`News.php:441-444`).
- **Usar `regionalId()` como teste de operador**: suporte/super-admin com `unidade_regional_id` recebem id e seriam tratados como restritos. O teste correto é `isRegionalOperator()`.
- **Contar com `authorize()` para responder JSON**: ele chama `abort(403)`, não `ApiResponse::error()`; em AJAX o corpo é o handler de exceção, sem envelope `ok/data/message/error`.
- **Excluir/renomear categoria regional no CMS esperando auto-recuperação**: só hard delete é recriado; soft delete é ignorado pelo model e a categoria "morta" continua sendo encontrada.
- **Criar categoria regional à mão com outro slug** (ex.: `regionais-pe`): o serviço nunca a encontrará e criará `regional-pe` em paralelo.

## 5. Inventário

| Arquivo | Papel | Efeito | Fonte |
|---|---|---|---|
| `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php` | serviço de domínio | provisiona/converge `v2_news_categories` a partir de `UnidadeRegional.acronym`; inverso slug→unidade; `syncAll` idempotente | `:14-136` |
| `V2/Core/Services/RegionalScopeService.php` | serviço de autorização | decide operador, filtra builders `RegionalOwnable`, autoriza registro, audita negativa | `:38-210` |

Consumidores diretos (levantados por grep):

| Consumidor | Chamada | Fonte |
|---|---|---|
| `UnidadeRegional::saved` | `sync($model)` | `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php:140` |
| `NoticiasCmsController@create/edit` | `syncAll()` | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:192,366` |
| `NoticiasCmsController` (filtro/força regional) | `unidadeForCategorySlug`, `newsCategoryFor` | `:908,941` |
| `UnidadeRegionalNewsApiController` | `newsCategoryFor($unidade)` | `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php:566` |
| `NoticiasImportService` | `newsCategoryFor($regional)` | `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php:279` |
| `DemoContentSeeder` | `newsCategoryParent`, `newsCategoryFor` | `V2/Seeds/Content/DemoContentSeeder.php:174,1391` |
| `InstituicaoCmsController` | `applyScope`, `authorize` | `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:51,187-488` |
| `NoticiasCmsController` | `applyScope`, `authorize` | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:101-130,364-563` |
| `RegionalRestrictMiddleware` | `isRegionalOperator`, `regionalId` | `V2/Core/Middleware/RegionalRestrictMiddleware.php:34,70` |
| `helpers.php` | `v2_is_regional_operator`, `v2_regional_id` | `V2/Core/Helpers/helpers.php:38,50` |
| `_editor-content.blade.php` | `v2_is_regional_operator`, `v2_regional_id` | `V2/Modules/Publicacoes/Noticias/Views/cms/includes/_editor-content.blade.php:22-23` |

## 6. Riscos de reimplementação

1. **Recriar o slug a partir de `name`** (como faz `str_slug` em outros models) cria `regional-sao-paulo` em vez de `regional-sp` e quebra o inverso e todas as categorias existentes.
2. **Adicionar `globalScope`** "para segurança" muda o comportamento de rotas públicas e de seeds, que dependem de ver tudo; o legado é explicitamente fail-open.
3. **Tratar `super-admin` como operador**: `NoticiasCmsController` usa `isRegionalOperator` para decidir campos travados; um super-admin restrito deixaria de conseguir editar notícias nacionais.
4. **Persistir a categoria no login** em vez de derivar sob demanda: a categoria pode ser apagada/renomeada, e o legado se recupera no próximo `syncAll`.
5. **Ignorar o `saving` de `NewsCategory`**: sem o sufixo de colisão, siglas duplicadas passam a estourar unique constraint e o `saved` engole a exceção → unidade fica sem categoria **e** o erro só aparece no log.
6. **Assumir `is_system` na subcategoria**: o legado deixa `false`; proteger as regionais contra exclusão é uma mudança de produto, não uma correção.

## 7. Lacunas

- **LG-CORE-DATA-11** — Não há teste ou seed que exercite `syncAll()` com unidade inativa/sem sigla; o comportamento descrito é derivado do código (`RegionalIdentityService.php:112-119`). Onde confirmar: `phpunit` do módulo UnidadeRegional (não executado neste levantamento).
- **LG-CORE-DATA-12** — Quantas unidades têm sigla duplicada ou com espaços no banco de produção é desconhecido; o schema não restringe. Onde procurar: `SELECT lower(acronym), count(*) FROM v2_unidade_regionais GROUP BY 1 HAVING count(*)>1`.
- **LG-CORE-DATA-13** — O `MODULE.md` e a issue `sucesu-be49` afirmam que views/JS usam `isRegionalSlug` e `data-is-regional`; o código atual não tem nenhum dos dois. Não é possível saber se a view foi refatorada e a documentação ficou para trás ou se houve regressão. Onde procurar: histórico git de `_editor-content.blade.php`.
- **LG-CORE-DATA-14** — O efeito de `abort(403)` em requisições da API da regional (que usam `ApiResponse`) não foi observado em execução; o handler pode devolver JSON padrão do Laravel (`{"message": "..."}`) ou HTML. Onde confirmar: request autenticada de outra regional em `/api/...`.

## 8. Checklist de paridade

- [ ] `PC-CORE-DATA-11` — Criar unidade `acronym='pe'`, `name='Pernambuco'`: existe `v2_news_categories` com `slug='regional-pe'`, `name='Regional PE'`, `parent_id` = id de `regionais`, `is_system=false`. Fonte: `RegionalIdentityService.php:41-51`.
- [ ] `PC-CORE-DATA-12` — Salvar a mesma unidade duas vezes não cria segunda categoria e não altera `updated_at` na segunda. Fonte: `:54-58`.
- [ ] `PC-CORE-DATA-13` — Unidade com `acronym=''` não cria categoria e o save não falha. Fonte: `:22-28`; `UnidadeRegional.php:138`.
- [ ] `PC-CORE-DATA-14` — Alterar `acronym` de `PE` para `PE2` cria `regional-pe2` e mantém `regional-pe`. Fonte: `:44-51`.
- [ ] `PC-CORE-DATA-15` — `unidadeForCategorySlug('regional-PE')` devolve a unidade; `'regionais'` e `'nacional'` devolvem `null`. Fonte: `:79-94`.
- [ ] `PC-CORE-DATA-16` — `isRegionalSlug` é `true` para `regionais`, `regional`, `regional-pe`; `false` para `nacional`. Fonte: `:129-136`.
- [ ] `PC-CORE-DATA-17` — Operador com `unidade_regional_id` lista só as próprias notícias; usuário sem o campo lista todas; model sem `RegionalOwnable` não é filtrado. Fonte: `RegionalScopeService.php:78-103`.
- [ ] `PC-CORE-DATA-18` — Acessar notícia de outra regional: HTTP 403 com mensagem `Acesso restrito aos dados da sua Unidade Regional.` e uma entrada `regional_access_denied` em `activity_log`. Fonte: `:134-142,194-210`.
- [ ] `PC-CORE-DATA-19` — Usuário com e-mail `config('v2.support.email')` ou role `super-admin` não é restringido, mesmo com `unidade_regional_id`. Fonte: `:169-180`.
- [ ] `PC-CORE-DATA-20` — `syncAll()` com 3 unidades ativas (2 com sigla, 1 sem) retorna `2`. Fonte: `RegionalIdentityService.php:108-122`.

## 9. Evidências consultadas

- `V2/Core/Services/RegionalIdentity/RegionalIdentityService.php` (1–137).
- `V2/Core/Services/RegionalScopeService.php` (1–235).
- `V2/Core/Contracts/RegionalOwnable.php` (1–39); `V2/Core/Middleware/RegionalRestrictMiddleware.php` (1–90); `V2/Core/Helpers/helpers.php` (30–52).
- `V2/Modules/Conexoes/UnidadeRegional/Models/UnidadeRegional.php` (99–150, 235–243); `V2/Modules/Conexoes/UnidadeRegional/MODULE.md` (76).
- `V2/Modules/Publicacoes/Noticias/Models/NewsCategory.php` (1–60); `.../Models/News.php` (425–486); `.../Controllers/NoticiasCmsController.php` (185–200, 355–372, 884–949).
- `V2/Modules/Conexoes/Associados/Models/AssociadoInstituicao.php` (66–129); `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php` (39–51, 185–200).
- `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalNewsApiController.php` (551–570); `V2/Modules/Publicacoes/Noticias/Services/NoticiasImportService.php` (275–282).
- `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php` (17–47); `.../2026_07_23_000002_create_v2_unidade_regional_empresas_table.php` (17–28).
- `config/v2.php` (4–6, 46–55); `.seeds/issues.jsonl` (issue `sucesu-be49`).

## 10. Checklist de extração (A–M)

| Item | Resposta |
|---|---|
| A1 | Provisiona e converge a subcategoria de notícias de cada unidade e restringe listagens/registros ao escopo do operador regional. |
| A2 | `UnidadeRegional::save()` com `acronym`; `syncAll()`; `applyScope($query)`; `authorize($model,$user,$action)`; requisição com Sentinel autenticado. |
| A3 | Linha em `v2_news_categories`; builder filtrado; `abort` 403; entradas de log/`activity_log`. |
| A4 | `Log::error` em falha de sync; `Log::debug` por listagem escopada; `ActivityLogger` para negativa; `Log::warning` se a auditoria falhar. |
| A5 | Unidade com sigla → categoria criada; sem sigla → `null`; não-pertença → 403 exato; operador em rota nacional → 403 texto ou redirect com flash. |
| A6 | Não remove categorias, não normaliza sigla, não filtra model não-contratado, não restringe suporte/super-admin, não faz cache. |
| A7 | Público: nenhum efeito direto (o portal lê categorias já existentes). CMS: categoria travada, listas escopadas, rotas nacionais bloqueadas. API da regional: categoria derivada do token. Suporte/super-admin: irrestrito. |
| B1 | Dois fluxos em §2.1/§2.2 (sync e escopo). |
| B2 | `sync` → `newsCategoryFor` → `newsCategoryParent`/`create`/`save`; `applyScope` → `isRegionalOperator` → `byUnidadeRegional`; `authorize` → `owns` → `belongsToUnidadeRegional` → `abort`. |
| B3 | Sigla como chave legível; sync no model por simplicidade; erro engolido por disponibilidade; fail-open para não quebrar módulos não adaptados (`[I]`). |
| B4 | Sete trechos em §2.3. |
| B5 | 2–3 queries por save de unidade; `syncAll` O(N) sem batch; `whereRaw LOWER(acronym)` sem índice; `whereHas` no pivô de associados; log `debug` por listagem. |
| B6 | Unidade criada → categoria; atualizada → reparo; desativada → categoria permanece; excluída → categoria permanece; categoria hard-deleted → recriada no próximo sync. |
| B7 | Sync é best-effort (try/catch no hook); escopo não transaciona; auditoria da negativa é best-effort; `authorize` aborta imediatamente. |
| C1 | Fonte única do mapeamento unidade↔categoria (`regional-<sigla>`) e da regra "quem é operador regional". |
| C2 | `v2_news_tags` usa `str_slug(acronym)` para tags (outro mecanismo, `NoticiasImportService.php:420`); não confundir com a categoria regional. |
| C3 | RN-CORE-DATA-09..16. |
| C4 | Sigla duplicada/com espaço/vazia; mudança de sigla; unidade inativa; categoria soft-deleted; concorrência; registro sem regional; vínculo inativo. |
| C5 | `syncAll` em GET; subcategoria `is_system=false`; `regionalId` ignora bypass; scope fail-open; categoria órfã sufixada. |
| C6 | Slug vem da sigla, não do nome; `LOWER(acronym)` não usa índice; soft delete não honrado pelo model; bypass avaliado por request. |
| C7 | `isRegionalSlug` sem call site; comentário `regionais-pe` vs. código `regional-pe`; `MODULE.md`/issue divergentes. |
| D1 | Assinaturas completas na ficha; precondições: `UnidadeRegional` persistida com `acronym`; `Sentinel` para resolução implícita de usuário. |
| D2 | Classes em `V2\Core\Services[...]`; sem binding; helpers globais; middleware registrado por classe nas rotas. |
| D3 | Requer tabelas `v2_news_categories`, `v2_unidade_regionais`, `users.unidade_regional_id`; `UnidadeRegional` deve existir antes do sync. |
| D4 | Blade: `v2_is_regional_operator()`, `v2_regional_id()`, campos `#unidade_regional_locked`/`input[name=unidade_regional_id]`; sem JS próprio. |
| D5 | Cinco receitas em §4.4. |
| D6 | Cinco anti-usos em §4.5. |
| D7 | `syncAll` cresce O(N); categoria `name` ≤100 (sigla ≤10); `authorize` aborta; sem timeout/quota própria. |
| D8 | Consumidores listados em §5; risco alto em Notícias/Associados/API da regional; médio no middleware e helpers. |
| D9 | Extensão: nova unidade implementando o campo `acronym`; novo model escopável implementando `RegionalOwnable`; nova rota nacional usando `RegionalRestrictMiddleware`. |
| E1–E7 | Não se aplica: o documento não declara rotas; o efeito HTTP relevante (403) está em A5/K3. |
| F1 | `v2_news_categories` (dono: Notícias) e `v2_unidade_regionais`/`v2_unidade_regional_empresas` (dono: UnidadeRegional) — o índice de tabelas core cobre apenas `v2_conexao_tags` e afins. |
| F2 | UUID v4 via `v2_uuid()`; `parent_id` é `string(36)` **sem FK**; `deleted_at` existe mas o model não usa soft delete. |
| F3 | `NewsCategory`: `$table`, `$fillable` (inclui `parent_id`,`is_system`,`sort_order`), `$casts.is_system=boolean`, hooks `creating`/`saving`, relações `parent/children/news`. |
| F4 | `UnidadeRegional::saved` → sync; `NewsCategory::saving` → dedupe de slug; nenhum observer dedicado. |
| F5 | `slug`/`name`/`parent_id` da regional escritos apenas pelo serviço; `is_system` só no pai. |
| F6 | 6 unidades no seeder de demonstração + `NAC` referenciada sem criação automática; 1 categoria regional por unidade; `syncAll` O(N); consulta crítica `where slug` (índice único). |
| G1–G6 | Não se aplica: telas de categorias/notícias pertencem a MOD-PUBLICACOES-NOTICIAS. |
| H1–H6 | Não se aplica: o portal consome as categorias, mas visibilidade/filtro são do módulo de Notícias. |
| I1–I8 | Não se aplica: sem componente JS; o blade citado só lê helpers. |
| J1 | `v2.support.email` (bypass), `v2.regional_operator.role_slug` e `v2.regional_operator.forced_news_category_slug` — ver `config.csv`. |
| J2 | Não há permissão própria; o bloqueio é por papel (`super-admin`) e por coluna (`unidade_regional_id`). |
| J3 | Não se aplica: sem `ModuleProvider`. |
| J4 | Não se aplica: sem flag de módulo; `is_active` da unidade é o único liga/desliga e não remove categoria. |
| J5 | Não se aplica: sem feature toggle. |
| K1 | `[V2 > RegionalScope]` nível debug; `[RegionalScopeService] Falha ao auditar...` nível warning; `[UnidadeRegional] Falha ao prover identidade regional:` nível error. |
| K2 | `activity_log` com descrição `regional_access_denied` e propriedades listadas em RN-CORE-DATA-14. |
| K3 | 403 no registro; 403 texto no middleware (AJAX) ou redirect+flash (web); `authorize` não devolve JSON. |
| K4 | Sem throttle/quota; custo por request é O(1) para escopo e O(N) para `syncAll`. |
| K5 | `user_email` e `unidade_regional_id` vão para log/auditoria; nenhum segredo. |
| L1 | Nenhum seeder próprio; `DatabaseSeeder`/`DemoContentSeeder` dependem de `newsCategoryParent`. |
| L2 | Categorias padrão `nacional` e `regionais` são semeadas pela migration de Notícias (`2026_07_22_000001:41-65`), não por este serviço. |
| L3 | Unidades regionais vêm de seeder do módulo; o Core só reage ao save. |
| M1 | PC-CORE-DATA-11..20. |
| M2 | LG-CORE-DATA-11..14. |
| M3 | §9. |
| M4 | Lint e índices parciais emitidos; consolidação é do coordenador. |
