# Fechamento — Ajustes v1 (design)

Data: 2026-07-14
Branch: `feat/fechamento-ajustes-v1`

Lote de 10 ajustes/correções ao fluxo de fechamento, organizados em quatro
workstreams. Uma migration de schema (coluna `tipo` em `documento`) e uma
migration de seed (novo relatório). O restante é lógica de validação, UI e
configuração.

Dependência entre itens: **7 → (8, 10)** — sem distinguir "NF" dos demais
anexos não há como identificar "quem não enviou NF". Os demais itens são
independentes.

---

## A · Lançamento de efetivo (itens 1–4)

Arquivos: `models/Efetivo.php`, `controllers/EfetivoController.php`.

Contexto: diárias são contadas por **número de entradas** de efetivo no
período (`Card::recalcularDiarias` conta linhas, não horas). Logo, os horários
de entrada/saída são informativos e não afetam o valor pago.

### Item 1 — Turno noturno (saída pode ser menor que a entrada)
- Remover o bloqueio de `Efetivo::validateSaidaAposEntrada` que rejeita
  `hora_saida < hora_entrada`.
- Justificativa: trabalhadores que entram (ex.) às 22h e saem às 6h. Como não
  há cálculo por duração, a saída anterior à entrada é interpretada como
  cruzando a meia-noite e é apenas informativa.

### Item 2 — Data retroativa exige hora de saída
- "Retroativo" = a data do `EfetivoDia` é **anterior a hoje** (`data < date('Y-m-d')`).
- Nova validação no model `Efetivo`: se o dia é retroativo e `hora_saida` está
  vazia → erro pt-BR: "Informe a hora de saída para lançamentos de dias
  anteriores."
- Por ser no model, vale para `actionAdicionar` **e** `actionAtualizarLinha`
  (que deixa de poder zerar a saída de um dia já passado).
- Lançamento de **hoje** mantém saída opcional (a pessoa pode ainda estar
  trabalhando). Líder-puro (que só lança "hoje") não é afetado.

### Itens 3 + 4 — Dobra no mesmo dia e líder que não aparece
Mesma raiz: a regra atual "1 efetivo por profissional por dia", aplicada em
dois pontos (`validateUnicoNoDia` no model e o filtro `jaLancados` no dropdown).

Regra nova de dobra (decisão do produto):
> A mesma pessoa pode ter mais de uma entrada no mesmo dia **se** toda entrada
> existente dela naquele dia (em qualquer obra) já tiver `hora_saida`
> preenchida **e** a nova `hora_entrada` for maior ou igual à última
> `hora_saida` registrada. Ex.: 1ª entrada 08–17 → a 2ª só pode ser lançada a
> partir das 17h.

- `Efetivo::validateUnicoNoDia` → renomeada para `validateDobraPermitida`:
  - Busca as demais entradas ativas do profissional na mesma **data** (qualquer
    obra), excluindo o próprio registro.
  - Se **alguma** delas estiver aberta (sem `hora_saida`) → erro: "Há um
    lançamento em aberto (sem saída) deste profissional neste dia. Registre a
    saída antes de lançar uma nova entrada."
  - Senão, se a `hora_entrada` da nova for **menor** que a maior `hora_saida`
    existente → erro: "A nova entrada deve ser a partir de HH:MM (após a saída
    anterior)."
  - Caso contrário, permitido.
  - Comparação de horário por clock-time `HH:MM` (`strtotime`), consistente com
    a validação atual.
- `EfetivoController::actionBuscarProfissional`:
  - O `jaLancados` deixa de excluir **todos** que já têm efetivo no dia; passa a
    excluir **apenas quem tem uma entrada aberta** (sem `hora_saida`) naquele
    dia — pois sobre uma entrada aberta não se pode dobrar ainda.
  - Efeito no item 3: o outro líder, que já fechou o ponto na própria obra,
    volta a aparecer no dropdown; a validação de dobra garante a coerência de
    horário no save.
- Diárias: cada entrada continua contando como 1 diária, então a dobra vira 2
  diárias no card naturalmente — sem mudança em `recalcularDiarias`.

---

## B · Bônus/Descontos no card (itens 5–6)

Arquivos: `views/card/fechar.php`, `web/js/card.js`, CSS dos itens do card.

### Item 5 — Botão de anexo no desconto não é clicável
- Causa provável: na linha de desconto o `<label class="card-nota-check">`
  ("Abate nota") está sobre/empurrando o `.ci-docs`, cobrindo o botão de clip.
  Confirmar na implementação (inspeção do layout) e ajustar o CSS da
  `.card-item-row` para o clip do desconto **já salvo** funcionar igual ao do
  bônus (abrir o modal `up-doc-<id>`).
- Mantém-se a regra "salvar o card 1x antes de anexar" (linha nova = item ainda
  sem `id` no banco). O clip de linha nova continua desabilitado; apenas
  tornar a mensagem ("Salve o card para anexar o comprovante") mais clara.

### Item 6 — Bônus/desconto meio-preenchido salva vazio sem avisar
- Hoje `CardController::actionSalvar` descarta silenciosamente linhas com
  descrição vazia **ou** valor ≤ 0.
- Em `card.js`, no submit do `#card-form`: varrer as `.card-item-row` de bônus e
  desconto; se alguma tiver **exatamente um** dos campos preenchido (descrição
  sem valor, ou valor sem descrição), `preventDefault` e exibir mensagem
  apontando a linha: "Preencha descrição e valor, ou limpe a linha."
- Linha totalmente vazia continua ignorada (comportamento atual).
- Validação é client-side (item de UX); o servidor mantém o descarte defensivo
  atual como rede de segurança.

---

## C · NF / documentos (itens 7, 8, 10)

Arquivos: migration em `documento`, `models/Documento.php`, `views/card/fechar.php`,
`controllers/CardController.php`, nova migration de seed de relatório,
`models/CardSearch.php`, `models/Card.php`, `views/card/_search.php`.

### Item 7 — Tipo de documento de pagamento
- Migration `mYYMMDD_NNNNNN_add_tipo_to_documento_table.php`: adiciona
  `tipo VARCHAR(20) NULL` em `documento`.
- `Documento`: constantes `TIPO_NF = 'nf'`, `TIPO_COMPROVANTE = 'comprovante'`,
  `TIPO_OUTRO = 'outro'`. Validação `in range` desses valores **quando**
  `owner_tipo = card_pagamento` (documentos de item de bônus/desconto seguem sem
  tipo). Label pt-BR e helper de rótulo.
- `views/card/fechar.php` (modal `up-pagto`): `<select>` de tipo (NF / Comprovante
  / Outro) aplicado ao upload. A lista de documentos de pagamento passa a exibir
  um badge do tipo.
- `CardController::actionUploadDoc`: quando `owner_tipo = card_pagamento`, lê o
  `tipo` do POST (default `outro` se ausente) e grava no `Documento`.

### Item 8 — Relatório "Cards sem NF"
- Nova linha seedada na tabela `relatorio` via migration no padrão de
  `m260619_000008_seed_relatorios.php` (slug `cards-sem-nf`, status ativo).
- SQL: cards com `total_nota > 0` e **sem** `documento` com `tipo = 'nf'` e
  `owner_tipo = 'card_pagamento'` (respeitando soft-delete: `deleted_at IS NULL`),
  join com profissional/função, filtrado por parâmetro de quinzena.
- Colunas: Profissional, Função, Quinzena, Valor da nota, Status.
- Aparece automaticamente como nova "aba" no índice de relatórios
  (`RelatorioController::actionIndex` lista os ativos).

### Item 10 — Filtro "sem NF" na lista de cards
- `CardSearch`: novo atributo booleano `sem_nf` (regra `boolean`).
- `Card::temNf(): bool` — helper compartilhado: `true` se existe documento de
  pagamento com `tipo = 'nf'` (não deletado). "Sem NF" para fins do filtro =
  `total_nota > 0 && !temNf()`.
- `CardController::montarLinhas`: quando `sem_nf` está marcado, filtrar as linhas
  ao final mantendo apenas cards que batem a definição acima (linhas sem card,
  ou com `total_nota` = 0, saem).
- `views/card/_search.php`: checkbox "Somente sem NF".
- Definição de "sem NF" é a mesma do relatório (item 8) — uma em SQL, outra em
  PHP via `temNf()`.

---

## D · Login com reCAPTCHA v3 (item 9)

Arquivos: `models/LoginForm.php`, `controllers/SiteController.php`,
`views/site/login.php`, `config/params.php`, `config/secrets.php`
(+ `config/secrets.example.php`, `.env.example`).

- Remover o CAPTCHA nativo do Yii: a regra `['verifyCode', 'captcha']` em
  `LoginForm`, o `captcha` em `SiteController::actions()` e o widget no
  `login.php`.
- Chaves em `secrets.php` (lidas do `.env` via `getenv`): `RECAPTCHA_SITE_KEY`
  e `RECAPTCHA_SECRET_KEY`, expostas em `params` como `recaptcha.siteKey` /
  `recaptcha.secretKey`. Atualizar `secrets.example.php` e `.env.example`.
- `views/site/login.php`: carregar `https://www.google.com/recaptcha/api.js?render=SITEKEY`;
  no submit, `grecaptcha.execute(SITEKEY, {action: 'login'})` preenche um hidden
  `recaptchaToken` no form.
- `LoginForm`: novo atributo `recaptchaToken` + `validateRecaptcha` — POST
  server-side a `https://www.google.com/recaptcha/api/siteverify`, exige
  `success === true && score >= 0.5`.
  - **Fail-open:** se o Google estiver inacessível (erro de rede/timeout), a
    validação passa (login não é bloqueado). Score reprovado ou token inválido
    (com resposta do Google) → erro pt-BR "Falha na verificação de segurança.
    Tente novamente."
- Observação operacional: o reCAPTCHA v3 exige que **o servidor** alcance o
  Google para o `siteverify`; o fail-open cobre indisponibilidade.

---

## Testes

Suítes existentes `Unit` e `Functional` (rodar dentro do container `php`).

- **Efetivo (Unit):** noturno liberado (saída < entrada válida); retroativo sem
  saída reprova e com saída aprova; dobra — bloqueia com entrada aberta,
  bloqueia com nova entrada antes da saída anterior, aprova com nova entrada
  após a saída.
- **Documento (Unit):** `tipo` válido/ inválido para `card_pagamento`; item de
  bônus/desconto sem tipo continua válido.
- **Relatório "cards-sem-nf" (Functional/SQL):** card com nota e sem NF aparece;
  card com NF anexada some; card com nota = 0 não aparece.
- **CardSearch/filtro (Functional):** `sem_nf` filtra corretamente via
  `Card::temNf()`.
- **LoginForm (Unit):** caminho fail-open (Google inacessível → validação passa);
  score abaixo do limite reprova. Chamada externa mockada/isolada.
- **Item 6** é validação client-side (JS) — verificar no fluxo real; sem teste
  unitário.

---

## Fora de escopo
- Refatorações não relacionadas ao lote.
- Alteração do cálculo de diárias por horas (permanece por nº de entradas).
- Tipagem de documentos em itens de bônus/desconto (só documentos de pagamento
  recebem `tipo`).
