# 05 — Regras de Negócio e Portabilidade do Módulo Diário Oficial

> Consolida as regras do módulo e as anomalias catalogadas no legado
> (`90-LEGADO/22-ANOMALIAS_E_RISCOS.md`,
> `90-LEGADO/CadeiaDeErros_ColunasPDF.md` e
> `90-LEGADO/2026-09-11-diario-upgrade-backlog.md`), reconferidas contra o
> código do `v6/`.

---

## 1. Regras de negócio não óbvias

| # | Regra | Evidência |
|:-:|-------|-----------|
| R1 | O `store`/`update` **não** gera PDF. O status de geração vai para `pending` e o PDF é produzido sob demanda | `v6/app/Http/Controllers/Prefecture/DiarioController.php` — `dispatchGeneration()`; `refresh_PDF_preview()` |
| R2 | A assinatura digital é aplicada **somente** quando `status = true` (publicado). Rascunhos saem sem assinatura | `v6/app/Services/FPdfService.php` — `public $isPublished = false;` e o gate em `Header()` |
| R3 | **Somente a área** define seção. A subárea não abre faixa nem quebra página ou coluna | `v6/app/Repositories/Eloquent/Prefecture/DiarioOficial/DiarioOficialRepository.php` — `groupBy('id_area')` com comentário explícito |
| R4 | `file_in = true` rasteriza o anexo (Imagick + Ghostscript) e o insere no fluxo de colunas; `file_in = false` importa as páginas uma a uma, cada uma em página inteira | `printAnexoMateria()` no repositório |
| R5 | O certificado **do diário** tem precedência sobre o certificado **padrão** das configurações; sem certificado a geração falha e devolve `false` | `generatePdfFile()` no repositório |
| R6 | Publicação exige `status_workflow = 2` e exige que o usuário não seja o autor (`created_by_user_id`/`updated_by_user_id`) | `DiarioController@publish` |
| R7 | Publicar define `status = true`, `permission_edit = false`, `published_by_user_id` e `published_at` | `DiarioController@publish` |
| R8 | `toogle_status` só reativa uma edição quando a segregação de funções está desligada **ou** a edição já está publicada | `DiarioController@toogle_status` + chave `diario_oficial_segregacao_funcoes` |
| R9 | O PDF é gravado de forma **atômica**: `Output('S')` → arquivo `.tmp.<uniqid>` → remoção do destino → `Storage::move` | `generatePdfFile()` no repositório |
| R10 | O PDF é servido com `Cache-Control: no-store, no-cache, must-revalidate, max-age=0` | `streamPdf()` no repositório |
| R11 | `show($id)` regenera quando `generation_status` **não** é `completed` nem `processing` | `DiarioController@show` |
| R12 | A visualização no portal é auditada **no máximo uma vez por edição por sessão** | `Portal\DiarioController@logPortalView` |
| R13 | O portal só expõe edições com `status = true` | `showPDF`, `search`, `filtroGET` |
| R14 | A navegação entre edições no portal é feita pelo **número da edição**, não pela data (duas edições podem ter a mesma data) | `Portal\DiarioController@showPDF` |
| R15 | A busca entre edições exige **no mínimo 3 caracteres** e devolve no máximo 20 resultados | `Portal\DiarioController@search` |
| R16 | O texto extraído do PDF é cacheado em JSON e invalidado pelo `mtime` do PDF | `extractPdfText()` em `Portal\DiarioController` |
| R17 | Todas as tabelas do módulo usam soft delete | `SoftDeletes` nas 6 models |
| R18 | A configuração global é um **registro único** (`diario_configs.id = 1`) | `DiarioController@get_configuration` e `@update_configuration` |
| R19 | A capa é comprimida limitando a altura a **1366px**, preservando a proporção; formatos `webp`, `avif`, `gif` e `svg` são ignorados | `attachCoverImage()` em `DiarioController` |
| R20 | A imagem de cabeçalho das configurações é comprimida para no máximo 1024×500 | `update_configuration()` em `DiarioController` |

## 2. Acoplamentos

### 2.1 Dependências do módulo (o diário depende de)

| Dependência | Ponto de acoplamento |
|-------------|-----------------------|
| `App\Prefecture\Configuration` | Chaves `titulo_site`, `cidade`, `logradouro`, `numero`, `bairro`, `estado`, `cep`, `favicon`, `diario_oficial_segregacao_funcoes` |
| `App\Prefecture\Archive` | Capa (`diarios.cover_image_id`) e cabeçalho (`diario_configs.header_img`) |
| `users` (Sentinel) | `created_by_user_id`, `updated_by_user_id`, `published_by_user_id` |
| `spatie/laravel-activitylog` | Trilha editorial e trilha pública (`activity()`) |
| `CategoryContent` (slug `diario-oficial`) | Publicação de conteúdo editorial no portal |
| `resources` / `menu_orders` / `modulos` | Autorização e menu do CMS |
| Dashboard (`App\Services\Dashboard\Widgets\DiarioWidget`) | Widget do painel |
| `Configuration` + Ghostscript + Imagick | Composição do cabeçalho e do PDF |

### 2.2 Dependências de outros módulos no diário (o diário é usado por)

| Consumidor | Uso |
|-----------|-----|
| Dashboard (`WidgetRegistry`, `DiarioWidget`) | Widget "diario" |
| Portal (`CategoryContent` slug `diario-oficial`) | Publicações do diário |
| Menu do CMS (`MenuServiceProvider`) | Submenu "Diário Oficial" |
| Auditoria pública (`getPublicAuditTrail`) | Trilha exposta no portal |

## 3. Armadilhas técnicas

| # | Armadilha | Onde | Impacto |
|:-:|-----------|------|---------|
| A1 | **`shell_exec` com senha e sem escape** — `openssl pkcs12 -in $nomeCertPFX -out $nomeCertCRT -nodes -passin pass:$password` | `v6/app/Http/Controllers/Prefecture/DiarioCertificadosController.php:67` | Superfície de **command injection** (a senha vem do request). O `Log::info` que registrava a linha de comando completa e expunha a senha foi removido no v6, mas o `shell_exec` sem `escapeshellarg` permanece |
| A2 | **QR Code em arquivo de nome fixo** — `QrCode::format('png')->generate($url, public_path('img/qrcode.png'))` | `v6/app/Repositories/Eloquent/Prefecture/DiarioOficial/DiarioOficialRepository.php:174` | Gerações concorrentes sobrescrevem o mesmo arquivo; um PDF pode receber o QR de outro diário |
| A3 | **`@set_time_limit(0)`** dentro da geração | mesmo repositório | Remove o limite de execução do request; a geração é síncrona e pode segurar worker por minutos |
| A4 | **Escrita com `Storage::put` + move** | mesmo repositório | Correto (atômico), mas depende de o `.tmp` e o destino estarem no mesmo filesystem |
| A5 | **`whereRaw` com JSON do PostgreSQL** — `properties::json->>'id_diario' = ?` | mesmo repositório, `getPublicAuditTrail` | Acopla a v7 ao dialeto JSON do PostgreSQL |
| A6 | **`session()` para deduplicar a auditoria** | `v6/app/Http/Controllers/Portal/DiarioController.php:99,111` | State em sessão; com cache de página, a auditoria pode não ser registrada |
| A7 | **`shell_exec` + `exec` para o Ghostscript** | `v6/app/Http/Controllers/Portal/DiarioController.php:281,296` | Depende do binário `gs` instalado; o comando é montado com `escapeshellarg`, mas o caminho do Ghostscript é resolvido em runtime |
| A8 | **Views SQL como contrato de API** | `v6/app/Http/Controllers/Prefecture/DatatableController.php` | O JS depende de nomes de coluna da view (`value`, `status_name`, `download_link`) |
| A9 | **N+1 em `diario_areas()`** | `Diario` | `hasManyThrough` não usa eager loading; `dados_diario` carrega `areas`, `subAreas` e `diario_areas` explicitamente para evitar isso |
| A10 | **Rotas duplicadas** em `basepro.php` | `v6/routes/basepro.php:255-259` e `:261-263` | Manutenção confusa |
| A11 | **Controller com duas responsabilidades** | `v6/app/Http/Controllers/Prefecture/DiarioAreaController.php` | O mesmo controller trata área e matéria, decidindo pelo conteúdo do request |
| A12 | **`mergePdfs` implementado e nunca chamado** | repositório (definição na linha 377) | O merge de anexos consecutivos não acontece: `_merged_path` (linha 908) nunca é preenchido. `cleanupMergedPdfs()` sobra como rede de segurança |
| A13 | **URLs montadas sem porta** | `v6/resources/views/prefecture/contents/diario-oficial/create.blade.php` | Correção aplicada no v6 (`window.location.origin`), mas o padrão é frágil |
| A14 | **`let collums` no escopo global** em cada `datatable.js` | `v6/public/assets/js/custom/contents/diario-oficial/datatable.js` | Funciona porque há uma tabela por página |
| A15 | **Nome de arquivo com typo** | `v6/app/Http/Model/Prefecture/DiarioCerticado.php` | A classe é `DiarioCertificado`; depende do classmap do Composer |
| A16 | **`diario_has_areas.path` guardada fora do `public/`** | `DiarioAreaController` | O PDF lê `public_path($materia->getOriginal('path'))`; se o arquivo não estiver no disco público, o anexo é descartado |
| A17 | **Coluna `adendo_id` inexistente** | `DiarioAreaController` (store/update) | Grava `adendo_id` que não existe no schema do v6; é ignorado silenciosamente porque não está no `$fillable` |
| A18 | **`toogle_status` lê `contents_id`** | `DiarioController@toogle_status` | Nome de parâmetro herdado de outro módulo. O handler genérico em `v6/public/assets/js/custom/utilities/table.js` envia `contents_id` com o id do botão, então o fluxo real funciona; o `index.js` do CMS, que envia `{ id }` e depende de `.toogle-status`, é **código morto** |

## 4. Código duplicado ou copiado entre v5 e v6

| Item | v5 | v6 | Observação |
|------|----|----|------------|
| Serviço de PDF | `v5/app/Services/FPdfService.php` (259 linhas) + 5 sub-serviços em `v5/app/Services/Pdf/` (`LayoutManager.php`, `HeaderRenderer.php`, `CoverHandler.php`, `ImageProcessor.php`, `PdfConverter.php`) | `v6/app/Services/FPdfService.php` (374 linhas, monolítico) + `v6/app/Services/Pdf/ImageProcessor.php` + `v6/app/Services/Pdf/PdfConverter.php` | O v6 **removeu** `LayoutManager`, `HeaderRenderer` e `CoverHandler`; o layout em colunas foi reimplementado dentro do `FPdfService` |
| Editor Summernote | `v5/public/assets/plugins/summernote/summernote-diario.js` (fork de 236 KB) | ausente no v6 | Remoção intencional (otimização) |
| Blades de preview | duplicados | `v6/resources/views/prefecture/contents/components/preview/diario-oficial/style.blade.php` e `v6/resources/views/prefecture/contents/components/preview/diario-oficial/summernote.blade.php` são cópias órfãs de `v6/resources/views/prefecture/contents/diario-oficial/pdf/` | Dívida técnica |
| Migrations | `v5/database/migrations/2025_09_19_*`, `2025_09_22_*` | `v6/database/migrations/2026_09_04_*`, `2026_09_10_*` | Mesmo efeito, nomes e datas diferentes; o **schema resultante é o mesmo** |
| Enum de tipos de matéria | `v5/app/Enum/Prefecture/DiarioOficial/DiarioHasAreasTypesEnum.php` | ausente | No v6 os literais `'text'`/`'file'` estão espalhados pelo código |
| Adendos | `v5/database/migrations/2025_09_20_010852_create_diario_adendos_table.php`, `2025_09_22_022119_add_adendo_id_column_to_diario_has_areas_table.php`, `v5/app/Http/Requests/DiarioAdendo/SaveRequest.php` | ausentes | Funcionalidade **morta já no v5** (sem model e sem rota); eliminada no v6 |
| Views `*_two` | `v5/database/migrations/2025_10_23_233343_create_view_to_areas_do_diario_two.php` e `v5/database/migrations/2025_10_23_235015_create_view_to_sub_areas_do_diario_two.php` | ausentes | Recriavam SQL idêntico, sem ganho |
| Recursos de menu | `v5/database/migrations/2023_02_14_094019_add_resource_to_diario_areas_and_subareas.php` | `v6/database/seeds/ResourcesTableSeeder.php` + `v6/database/seeds/MenuOrderSeeder.php` | Migrado de migration para seed |

## 5. Onde o v6 divergiu do v5 (e por quê)

| Área | v5 | v6 | Motivo |
|------|----|----|--------|
| **PDF anexos** | Merge de anexos consecutivos do mesmo escopo (`mergePdfs`) com render em base64 dentro das colunas | Grid 4-up com `Image()`, fallback simétrico import ⇄ imagens | Decisão de escopo do backlog: manter o grid v6 e adicionar o merge |
| **Colunas** | `LayoutManager` com `checkIfIsNeedBreakPage` ciente de colunas e altura dinâmica de imagem | `FPdfService` monolítico com `y > 240` / `x > 91` | Consolidação do serviço; o motor de colunas do v5 ficou como backlog (Fase 4) |
| **Assinatura** | Assinava sempre | Assina só quando publicado | Separação entre rascunho e publicação (F1.2) |
| **Escrita do PDF** | `Storage::put` direto | `.tmp` → `move` (atômico) | Evitar PDF corrompido (F1.1) |
| **Capa** | Altura limitada a 1366px, sem transação | Altura limitada a 1366px **e** transação | Preservar qualidade e atomicidade (F2.2) |
| **Filtro do portal** | Filtro por data/termo/edição em SQL | Filtro por data/termo/edição em SQL (restaurado) | Correção do filtro em memória (F2.5) |
| **Assinatura de FK da capa** | `onDelete('cascade')` | `onDelete('set null')` | Mais seguro: apagar o arquivo não apaga a edição |
| **Workflow** | Sem workflow | `status_workflow` + 4 olhos + revisor | Feature exclusiva do v6, a preservar |
| **Auditoria pública** | Ausente | `auditoria` + `getPublicAuditTrail` | Feature exclusiva do v6, a preservar |
| **Capa no PDF** | Ausente | `cover_image_id` + `addCoverPage()` | Feature exclusiva do v6, a preservar |
| **Polling de geração** | Ausente | `generation_status` + `generationStatus()` + badge | Feature exclusiva do v6, a preservar |
| **Download** | Ausente | `?download=1` em `streamPdf` | Feature exclusiva do v6, a preservar |

## 6. A cadeia de erros das colunas (histórico que deve ser preservado)

O incidente de 29/06/2026 e a correção de 09/07/2026 estão detalhados em
`90-LEGADO/CadeiaDeErros_ColunasPDF.md`. Resumo, porque são armadilhas para a
reconstrução:

1. **`DiarioConfig` nulo** — `DiarioConfig::query()->first()` podia devolver
   `null` e o log chamava `->toArray()` nele: crash fatal que impedia gerar
   qualquer PDF. O código atual trata a ausência de configuração com `data_get()`
   e fallbacks (`logo_position` padrão `center`).
2. **FPDI não suporta CrossReference Streams** — PDFs modernos (PDF 1.5+) falham
   em `setSourceFile`. O `PdfConverter` hoje conta páginas com **FPDI** e cai
   para **Imagick** (`getNumberImages`) quando o FPDI falha. **Esse fallback é
   obrigatório na v7.**
3. **Altura fixa das imagens** (`max-height: 75mm`) + `writeHTML($html, true, false, true, false, '')`
   (que já executa `Ln($this->lasth)` internamente) + `resetColumns()` no fim do
   loop → quebra de espaço nas colunas. As correções aplicadas foram:
   altura dinâmica, `writeHTML` com `$ln = false`, `selectColumn()` antes de
   cada `writeHTML` e remoção do `resetColumns()` do laço.
4. **`checkIfIsNeedBreakPage` ciente de colunas** — deve avançar para a próxima
   coluna em vez de criar uma página, mas **sempre em modo single-coluna**
   quando a verificação é feita antes de trocar de coluna; caso contrário, a
   última página de um PDF com número ímpar de páginas desaparece (15 páginas em
   vez de 16).

> ⚠️ **Atenção:** o `LayoutManager.php` citado nessa cadeia existe **apenas no
> v5** (`v5/app/Services/Pdf/LayoutManager.php`). No v6 essas regras vivem
> dentro de `v6/app/Services/FPdfService.php`.

## 7. Checklist de paridade funcional para a v7

- [ ] O PDF **não** é gerado no `store`/`update`.
- [ ] A assinatura aparece **apenas** em edições publicadas.
- [ ] A subárea **não** cria seção nem quebra de página.
- [ ] `file_in = true` rasteriza; `file_in = false` importa página a página.
- [ ] O certificado do diário sobrepõe o certificado padrão; sem certificado, falha.
- [ ] Publicação exige estado "em revisão" e exige um segundo usuário.
- [ ] Publicar trava `permission_edit` e registra autor e data.
- [ ] A escrita do PDF é atômica e o arquivo antigo não é servido do cache.
- [ ] A conversão de PDF usa FPDI com **fallback para Imagick**.
- [ ] O layout em colunas não deixa espaço residual nem perde a última página.
- [ ] A auditoria de visualização no portal ocorre uma vez por sessão e por edição.
- [ ] A busca entre edições exige 3 caracteres e a busca no PDF respeita acentos.
- [ ] O filtro do portal filtra **no banco** por data, termo e edição.
- [ ] O QR Code aponta para `{APP_URL}/diario-oficial/{id}`.
