# Terceirizados — Regras e Portabilidade

> Módulo **existente apenas na `v5`**. Este arquivo lista as regras de negócio não
> óbvias, os acoplamentos e as **armadilhas** reais encontradas no legado.

---

## 1. Regras de negócio

| # | Regra | Onde está | Consequência se violada |
|---|---|---|---|
| R1 | A folha é organizada por **competência** (ano + mês), não por pessoa. O mesmo empregado pode aparecer em N registros (um por mês). | `outsourceds.year` / `outsourceds.month` | Não há chave natural; não há unicidade. |
| R2 | `year` é limitado a `[2000, date('Y') + 1]`. O limite é recalculado a cada carregamento da classe de validação. | `v5/app/Http/Requests/OutsourcedRequest.php` | O mesmo código aceita anos diferentes conforme a data de execução. |
| R3 | `month` é inteiro 1–12; o nome por extenso é resolvido por um mapa **embutido em vários arquivos** (controller do portal, `gerarTxt`, `DatatableController`, view de exportação, table do portal, `show.blade.php` de cada template e o `<select>` do formulário do CMS). | ver §4 | Divergência de rótulos entre telas se um dos mapas for alterado. |
| R4 | O portal só mostra registros com `is_show = true`. O CMS **não tem** nenhuma tela para alterar `is_show`, e o `store`/`update` forçam `true`. | `v5/app/Http/Controllers/Prefecture/OutsourcedController.php` | A flag só pode mudar por manipulação direta do banco. Na prática, todo registro é público. |
| R5 | A listagem do CMS (`DatatableController`) **não** filtra `is_show` — mostra todos os registros, públicos ou não. | `v5/app/Http/Controllers/Prefecture/DatatableController.php` | Inconsência entre as duas faces. |
| R6 | Anexos são exclusivamente **PDF** (`mimes:pdf`). Não há limite de tamanho no `FormRequest` (fica no `php.ini` / `post_max_size`). | `v5/app/Http/Requests/OutsourcedRequest.php` | Uploads grandes podem falhar com 500 em vez de mensagem de validação. |
| R7 | A **edição soma** anexos; não substitui. O usuário **não** tem como reordenar ou trocar a descrição de um anexo já enviado. | `update()` no controller CMS | Acúmulo de anexos sem controle. |
| R8 | A remoção de anexo remove **o vínculo**, não o arquivo físico. | `destroyArquivo()` | Órfãos no storage. |
| R9 | A exclusão de registro é *soft delete*; os arquivos permanecem no storage. | `SoftDeletes` no model | Crescimento do storage sem limpeza correspondente. |
| R10 | A exportação respeita os **filtros** da consulta (o `type` é lido da query string), mas **ignora a paginação**: exporta tudo que casar. | `Portal\OutsourcedController@index` | Arquivos grandes para bases extensas. |
| R11 | A ordenação do portal é fixa: `year desc, month desc, employee_name asc`. Não há ordenação por outro critério nem escolha do usuário. | `Portal\OutsourcedController@index` | — |
| R12 | O contador `menu_orders.clicks` é incrementado **a cada abertura da listagem do CMS**, não por visita ao portal. | `menuClicks('terceirizados')` | Métrica de acesso ao admin, não ao portal. |
| R13 | O log de atividade registra a *listagem* manualmente ("Listou Terceirizados") e as escritas automaticamente via `LogsActivity`. | model + `index()` | A descrição do log de criação/edição/exclusão é `"O Terceirizado {nome} foi {evento}"`. |

## 2. Acoplamentos

### Dependências do módulo → sistema

| Acoplamento | Ponto de contato | Gravidade |
|---|---|---|
| Permissões Sentinel | `check.route.permission` + `has_access(['terceirizados.*'])`; resource `terceirizados` (id 14) em `resources` | **Alta** — o módulo depende do esquema de permissões por slug. |
| Layout do portal | `configurations.key = layout` → `Admin\Controller::getTemplateActived()` → nome da view | **Alta** — ver armadilha A6. |
| Menu do CMS | `menu_orders.slug = 'terceirizados'` + `MenuServiceProvider` | Média. |
| Auditoria | `spatie/laravel-activitylog` + `activity_log` | Média. |
| Mensagens | `laracasts/flash` (helpers `flash()`) | Média. |
| Transparência Fiscal | `static_pages` / `contents` / `links` com slug `terceirizados` (criados pelo seeder, somente para estado `PB`) | Baixa. |
| Storage | `storage/app/public/terceirizados` + symlink `public/storage` | Média. |

### Dependências do sistema → módulo

| Quem consome | Como |
|---|---|
| Portal da Transparência Fiscal | Card "Terceirizados" com link externo (`links.link`) apontando para `/terceirizados` — criado por `v5/database/seeds/AddTerceirizadosInTransparenciaFiscalSeeder.php`. |
| Menu do CMS | `case 'terceirizados'` em `v5/app/Providers/MenuServiceProvider.php:182`. |
| Auditoria | `activity_log` consultada em `/cms/log`. |

O módulo **não** é consumido por nenhum outro módulo do sistema (nenhuma FK, nenhuma
view SQL, nenhum job).

## 3. Armadilhas reais encontradas no legado

### A1 — Exclusão em lote envia `toDelete`, o controller lê `ids`

`v5/public/assets/js/custom/utilities/deleteAll.js` envia `toDelete: [...]`, mas
`v5/app/Http/Controllers/Prefecture/OutsourcedController.php:157` faz:

```php
public function destroyAll(Request $request)
{
    return $this->outsourcedRepository->destroyAll($request->ids);
}
```

`$request->ids` é `null` → `Repository::destroyAll` executa
`whereIn('id', null)` → nada é apagado e a resposta é `false` (o JS então exibe a
mensagem de sucesso padrão, porque o `success` só substitui a mensagem quando
`response.success` é truthy — e o retorno é o JSON `false`, não um objeto).

Comparação com os demais módulos do CMS, que leem `$request->toDelete`:

| Controller | Campo lido |
|---|---|
| `Prefecture\ContentController@destroyAll` | `toDelete` |
| `Prefecture\DiarioController@destroyAll` | `toDelete` |
| `Prefecture\FilaEsperaSusController@destroyAll` | `toDelete` |
| `Prefecture\OutsourcedController@destroyAll` | **`ids`** |

**Impacto:** a exclusão em lote de terceirizados **não funciona**.

### A2 — `file_path` guarda um JSON dentro de uma coluna de texto

Três lugares distintos redecodificam o mesmo campo, cada um com seu próprio fallback
(ver `02-MODELAGEM.md` §2). Consequências:

- Não há accessor: qualquer consumidor novo precisa repetir o `json_decode` + fallback.
- Um JSON truncado (coluna `varchar(255)` antes da migration de 28/05/2026) é
  interpretado como "caminho único" — o arquivo some da lista sem erro visível.
- `file_path` mistura **dado de negócio** (o vínculo com o arquivo) com **metadado de
  storage** (o caminho relativo), o que impede trocar o backend de arquivos sem migrar
  dados.

### A3 — `json_encode` sem `JSON_UNESCAPED_UNICODE` e com acento quebrado

O JSON é gerado com `json_encode($items)` (flags padrão). Acentos viram a sequência de
escape Unicode (`\u00e9` para "é") — não quebra a leitura (`json_decode` reverte), mas o
campo fica ilegível em dumps SQL e ocupa mais espaço na coluna `text`.

### A4 — `update()` nunca grava `file_path` quando não há upload

`$data` só recebe `file_path` dentro do `if ($request->hasFile('arquivo'))`. Como
`upInsert` usa `updateOrCreate` com o array `$data` como **atributos a atualizar**, a
ausência da chave preserva o valor anterior — comportamento correto, mas implícito:
se alguém refatorar para `fill()` + `save()` sem cuidado, os anexos são apagados.

### A5 — `$outsourceds_all` é materializado em toda requisição do portal

```php
$query = $filter->filter($request);

$outsourceds_all = $query->orderBy('year', 'desc')
                         ->orderBy('month', 'desc')
                         ->orderBy('employee_name', 'asc')
                         ->get();

if ($request->has('type')) {
    return $this->export($outsourceds_all, $request->type);
}

$outsourceds = $query->paginate(15);
```

A collection completa é carregada **mesmo quando o usuário só quer ver a página 1**;
depois a mesma query é executada de novo para paginar. Custo: 2 queries + memória
proporcional ao total, em um módulo que já pagina.

### A6 — A view do portal é resolvida pelo template ativo, e nem todos o têm

`Admin\Controller::getTemplateActived()` lê `configurations.key = layout` e monta
`portal.layouts.<layout>.terceirizados.index`. Só `modelo1`..`modelo6` possuem esse
diretório. Os layouts `default`, `newsbit`, `barra-privacidade`, `busca-darias`,
`calendar-events`, `contratos`, `diario-oficial`, `dynamics-forms` e `surveys` **não**
possuem as views → **404** na página pública.

O próprio `getTemplateActived()` só valida a existência de
`portal.layouts.<layout>.home.index`, então o problema só aparece ao acessar
`/terceirizados`.

### A7 — Filtro de busca com `ilike` e curingas não escapadas

`v5/app/Http/Filter/FilterOutsourceds.php`:

```php
$result->when($request->busca, function ($query) use ($request) {
    $search = '%' . $request->busca . '%';
    return $query->where(function ($q) use ($search) {
        $q->where('employee_name', 'ilike', $search)
          ->orWhere('company', 'ilike', $search);
    });
});

$result->when($request->funcao, function ($query) use ($request) {
    return $query->where('role', 'ilike', '%' . $request->funcao . '%');
});
```

Observações:

- O valor é **parametrizado** (prepared statement) — não há injeção de SQL.
- Mas os curingas `%` e `_` digitados pelo usuário **não são escapados**, então um
  usuário pode forçar varredura total (`busca=%`) ou casar qualquer caractere (`_`).
  O `ilike` também não usa índice, e a tabela não tem índice sobre `employee_name`.
- A busca livre **não** cobre `role` (só a empresa e o empregado) — o filtro `funcao`
  cobre a função, mas com `ilike` e sem acento-insensitivity.
- `->when($request->busca, ...)` testa **truthiness**: `busca=0` (string) é ignorado,
  pois `"0"` é falsy em PHP.

### A8 — Sem índice para o padrão de consulta do portal

A tabela só tem o índice primário. Ordenar por `year desc, month desc` e filtrar por
`ilike` exige varredura sequencial + ordenação em memória.

### A9 — `is_show` é gravado mas nunca lido no CMS

`store`/`update` forçam `is_show = true`; nenhum formulário, JS ou endpoint altera a
flag. O filtro do portal (`is_show = true`) é, portanto, redundante na prática.

### A10 — `OutsourcedRequest::authorize()` retorna `true`

A autorização real é feita pelo middleware `check.route.permission`, que compara o
**nome da rota** com as permissões do Sentinel. Não há `Policy` nem checagem no model.
Consequência: qualquer rota nova que use o model precisa lembrar de nomear-se
`<resource>.<ação>` para ser autorizada corretamente.

### A11 — `Repository::upInsert` faz `dd($th)` em caso de exceção

`v5/app/Repositories/Repository.php`:

```php
} catch (\Throwable $th) {
    dd($th);
}
```

Uma exceção de banco **derruba a aplicação** com um dump HTML, em vez de retornar uma
mensagem de erro. O `try/catch (Exception $e)` do controller não alcança esse `dd()`,
porque o `dd()` é lançado dentro do repository. Note ainda que `dd()` lança uma
exceção que aborta a execução antes do `return false` final.

### A12 — `show()` do CMS redireciona para o portal

`Prefecture\OutsourcedController@show($id)` faz
`redirect()->route('portal.terceirizados.show', $id)`. Se o registro estiver com
`is_show = false` ou deletado, o destino devolve **404** (o `indexShow` usa
`firstOrFail()`). Não há tratamento no CMS.

### A13 — Índice do anexo é posicional

`route('terceirizados.destroy-arquivo', [$outsourced->id, $arqIndex])` — o índice é a
posição no array JSON. Após remover um item, `array_values()` renumera os demais; se a
página não for recarregada, o segundo clique pode remover o anexo errado.

### A14 — `is_show`/`file_path` fora do `FormRequest`

`is_show` e `file_path` são `fillable` no model, mas não vêm do formulário (o controller
os define). `arquivo_desc` não tem validação nem limite de tamanho — é gravado dentro
do JSON da coluna `text`.

### A15 — Regras de validação calculadas no carregamento da classe

`'year' => 'required|integer|min:2000|max:'.(date('Y') + 1)` — a concatenação acontece
quando o arquivo é carregado (por requisição, via autoload), então o limite acompanha o
ano corrente. Não é um bug, mas é implícito: em testes é preciso fixar o tempo.

### A16 — `Content-Type: plain/txt` no download TXT

`exportTxt()` envia `'Content-Type' => 'plain/txt'`, que não é um MIME type registrado;
o correto seria `text/plain`. Navegadores costumam tratar como texto, mas o tipo é inválido.

### A17 — CSV e XLSX compartilham a mesma classe de exportação

`Excel::download(new OutsourcedsExport($outsourceds), 'terceirizados.csv')` — o Maatwebsite
infere o driver pela extensão. O conteúdo exportado é o **HTML da view** convertido,
não um CSV "limpo"; o resultado contém marcação residual e quebras de layout.

### A18 — Sem sanitização do texto livre exportado

Os campos são escapados pelo Blade (`{{ }}`), mas o `exports/outsourceds.blade.php` é
usado tanto para Excel quanto para PDF: o DomPDF interpreta HTML, então um valor com
`<` malformado pode alterar a renderização do PDF.

## 4. Código duplicado no módulo

| Trecho duplicado | Ocorrências |
|---|---|
| Mapa de meses 1–12 → nome em pt-BR | 12 cópias: `Portal\OutsourcedController@index`, `Portal\OutsourcedController@gerarTxt`, `v5/app/Http/Controllers/Prefecture/DatatableController.php:125`, `v5/resources/views/exports/outsourceds.blade.php`, `v5/resources/views/portal/components/terceirizados/table.blade.php`, `v5/resources/views/prefecture/outsourceds/form.blade.php` e o `show.blade.php` de cada um dos 6 templates do portal. |
| Normalização do JSON de `file_path` | 4 pontos de código: `update()`, `destroyArquivo()`, `form.blade.php` e `show.blade.php` (este último repetido nos 6 templates). |
| Bloco de listagem/ficha do portal | `modelo1`..`modelo6` (6 cópias de `index.blade.php` e 6 de `show.blade.php`, com pequenas diferenças de classe CSS). |
| Filtro do portal | `modelo1`..`modelo6` (6 cópias de `filtro.blade.php`). |

## 5. Divergências v5 → v6

A v6 **não possui** o módulo Terceirizados. Não há controllers, model, request, export,
views, rotas, filtro nem repositório. O único vestígio é o seeder
`v6/database/seeds/AddTerceirizadosInTransparenciaFiscalSeeder.php` (cópia do da v5),
que referencia tabelas (`static_pages`, `contents`, `links`) e cria um link para
`/terceirizados` — rota que **não existe** na v6.

Detalhamento completo (tabela, resource, menu_orders, rotas) em
`08-GAPS-E-DIVIDAS.md`.

## 6. Recomendações de portabilidade (síntese)

1. Modelar os anexos como **entidade própria** (`outsourced_attachments`) em vez de JSON
   em coluna `text`.
2. Extrair o mapa de meses para um **enum** (`MonthEnum`) ou para a coluna `month` já
   convertida em data.
3. Trocar `ilike` com `%` por busca indexada (`pg_trgm` + GIN, ou busca full-text).
4. Eliminar a materialização total da collection no portal (A5).
5. Corrigir o bulk delete (`ids` → `toDelete`) ou remover o botão em lote.
6. Apagar fisicamente o arquivo ao remover o vínculo (A8/R8) ou implementar job de
   limpeza de órfãos.
7. Garantir que **todos** os layouts do portal tenham a view do módulo (ou renderizar um
   layout genérico).
8. Substituir `check.route.permission` por Policies do Laravel 11.
9. Escapar `%`/`_` na busca e normalizar acentos.
10. Trocar `dd($th)` por `report($th)` + resposta de erro.
