# F1.6 — Agendamento

Levantamento feito em 27/08/2026, lendo o código **e o crontab de produção**.

O portão da frente pede **cada rotina com dono declarado**: quem dispara, com que
frequência, e o que acontece se falhar. As três estão respondidas.

Uma parte deste documento foi escrita antes do crontab chegar, e errou. O erro
está corrigido e registrado — ver o D43 — porque a forma dele se repete: procurei
rotas num prefixo só.

---

## O que existe

| | |
|---|---|
| Ações de cron no controlador | **29** — eram 30 até o D43 sair em 31/08 |
| Com rota, e agendadas | **25** |
| Com rota, e **não agendadas** | **1** — `conveniosAtualizarVencimento`. Era 2: `yungasNpsAtualizar` foi **removida** em 31/08 (D43) |
| Sem rota, e não são cron | 3 — endpoints de consulta em `/api/unidade/` |
| Rotinas dentro do banco, como `EVENT` | 2 |
| **Com trava contra execução simultânea** | **5 de 30** (D44) |

As rotas vivem em três prefixos — `/api/cronjobs/`, `/api/marketing/` e
`/api/cupons/` — todas protegidas pelo **mesmo token**, escrito no código até
03/10/2026 e desde então lido do `cron.token` do `local.php` (SEGREDOS, D4).
Quem souber a URL e o token dispara qualquer uma.

---

## O que cada rotina assume, e o que quebra se falhar

A janela é o que o **código** assume, lida das expressões de data; o horário real
está na tabela do crontab, mais adiante.

| Rotina | Janela que o código assume | O que quebra se falhar |
|---|---|---|
| `notificarAniversariantes` | dia e mês de hoje | ninguém recebe parabéns naquele dia |
| `clientesNotificarPrimeiraVisita` | ontem | quem veio pela primeira vez não recebe a pesquisa |
| `clientesNotificarSentimosSuaFalta` | 30 dias atrás | a campanha de retorno não sai |
| `clientesAtualizarConsumo` | últimos 2 dias: quem veio e, desde 04/11/2026 (a CA7 da Carteira, o D698), quem teve venda cancelada | o consumo do cliente fica desatualizado; um dia perdido não se recupera para quem não volta (a janela é de 2 dias) |
| `comandasCancelar` | — | comandas abertas ficam abertas |
| `cuponsValidarVencimento` | — | cupom vencido continua valendo; e, desde 11/10/2026 (o D264), a campanha encerrada sem cupom válido fica entre as ativas |
| `fidelizacaoAtualizar` | hoje | fidelização não avança |
| `vencimentoPontuacaoProgramaFidelidade` | — | pontos vencidos continuam valendo |
| `npsContabilizar` | mês e ano correntes | NPS não fecha o mês |
| `npsContabilizarUnidadesColaboradores` | — | NPS por unidade e colaborador não fecha |
| `npsNotificacoes` | — | a pesquisa não é enviada |
| `relatoriosAtendimentosAtualizar` | ano e dia correntes | relatório de atendimentos congela |
| `relatoriosDashboardFaturamentoAtualizar` | últimos 3 meses | o dashboard de faturamento congela |
| `relatoriosRoyaltiesAtualizar` | ontem e hoje | **royalties não são calculados** |
| `relatoriosRoyaltiesFaturasAtualizar` | — | faturas de royalties não atualizam |
| `relatoriosRoyaltiesFaturasGerar` | — | faturas de royalties não são geradas |
| `unidadesTicketMedioAtualizar` | — | ticket médio congela |
| `yungasFaturamentoAtualizar` | mês e ano correntes | integração Yungas desatualizada |
| `conveniosAtualizarVencimento` | — | **nunca agendada** — convênio com contrato vencido continua valendo |

*Saíram em 13/10/2026* (a resposta 137 do dono; o D307, pontos 3 e 4):
`relatoriosDashboardColaboradoresAtualizar` (a do D40, que gravava o mês corrente de
`dashboard_colaboradores`) e `relatoriosDashboardFluxoMovimentoAtualizar` (a que
gravava `dashboard_movimentos`), com as telas que liam as tabelas delas -- o Dash.
Colaboradores, o Radar de Temperatura e o Fluxo de Movimento. O Profissional Parceiro
e o painel calculam na hora, das vendas. As duas tabelas ficam guardadas, e ninguém
mais grava nelas.

*Saiu em 25/10/2026* (a resposta 138 (a) do dono; o D306 e o D105):
`relatoriosFidelizacaoAtualizar`, a das 03:10, que gravava um arquivo por unidade em
`data/cache/relatorios/fidelizacao/` que nenhuma tela lia -- o item "Fidelização de
Clientes" do menu estava escondido e levava a um controlador que nunca existiu. A
fidelização ao colaborador mora no Profissional Parceiro. Os arquivos já gravados ficam;
ninguém mais os escreve. A irmã `fidelizacaoAtualizar`, das 23:50, é outra coisa (desfaz
a fidelização do cliente sem retorno) e fica.

*Saiu em 04/11/2026* (a fatia 76d, a CA3 de `docs/f3-relatorios/spec-carteira.md`; a
resposta 138 (a) do dono; o D691): `relatoriosEvasaoAtualizar`, a das 04:10, que gravava
um arquivo por unidade em `data/cache/relatorios/evasao/`, com nome, telefone e e-mail de
clientes, que só o PDF da Evasão lia. A tela antiga saiu com ela, e a Carteira de clientes
conta na hora, na tela nova. Os arquivos já gravados ficam, pela resposta 290 (b) do dono (o
D693); ninguém mais os escreve. A irmã do mesmo horário, `unidadesTicketMedioAtualizar`, é
outra coisa (o ticket médio das unidades) e fica.

**A de royalties é a única que mexe em dinheiro** e não tem trava. Ela grava com
o mesmo padrão de ler-e-criar-se-não-existe do D40, então está exposta à mesma
duplicação.

---

## O crontab de produção, lido em 27/08/2026

**25 entradas, todas via `curl` para HTTPS com o token na URL.** O agendamento
real, ao lado da janela que o código assume:

| Quando | Rotina | Tempo limite |
|---|---|---|
| `*/1` das 8h às 22h | `marketing/marketingPush` | 55 s |
| `*/5` | `npsNotificacoes` | 180 s |
| `*/10` | `npsContabilizar` | 180 s |
| `*/10` | `marketing/envios` | 590 s |
| 01:10 | `clientesAtualizarConsumo` | 600 s |
| 01:20 | `cupons/cuponsValidarVencimento` | 300 s |
| 02:10 | `relatoriosAtendimentosAtualizar` | 600 s |
| 04:10 | `unidadesTicketMedioAtualizar` | 600 s |
| 05:15 | `npsContabilizarUnidadesColaboradores` | 600 s |
| 05:25 | `relatoriosRoyaltiesAtualizar` | 600 s |
| 05:35 | `relatoriosDashboardFaturamentoAtualizar` | 600 s |
| 06:00 | `notificarAniversariantes` | 600 s |
| 07:00 | `clientesNotificarSentimosSuaFalta` | 600 s |
| 08:30 | `clientesNotificarPrimeiraVisita` | 600 s |
| 10:00 | `marketing/marketingPushOutros` | 300 s |
| 12:30 | `relatoriosRoyaltiesFaturasAtualizar` | 600 s |
| 23:00 | `vencimentoPontuacaoProgramaFidelidade` | 600 s |
| 23:40 | `yungasFaturamentoAtualizar` | 600 s |
| 23:50 | `fidelizacaoAtualizar` | 600 s |
| 23:58 | `comandasCancelar` | 300 s |
| 09:00 do dia 16 | `relatoriosRoyaltiesFaturasGerar` | 600 s |

**Duas linhas saíram desta tabela em 13/10/2026, e não da produção:** a das 23:30
(`relatoriosDashboardColaboradoresAtualizar`) e a das 07:00 do dia 1
(`relatoriosDashboardFluxoMovimentoAtualizar`). As rotinas saíram do código (a resposta
137 do dono), e a produção, congelada, continua chamando as duas no código de lá até o
corte. O passo 9 de `docs/procedimento-de-corte.md` as tira do crontab guardado antes de
instalar o novo -- sem isso, as duas voltariam no corte e dariam 404 todo dia, com uma
linha `falhou` no log do sistema a cada vez (o aviso do crontab novo, desde 07/11/2026). Das 25 entradas lidas em 27/08/2026, o crontab do corte fica com 21.

**A das 03:10 saiu desta tabela em 25/10/2026, pelo mesmo caminho** (a resposta 138 (a)
do dono): `relatoriosFidelizacaoAtualizar` saiu do código, a produção continua chamando-a
no código de lá até o corte, e o mesmo primeiro bloco do passo 9 a tira do crontab
guardado -- e o recuo do passo 12 da etapa 1 (`procedimento-de-corte-etapa-1.md`) também.

**A da Evasão saiu da linha das 04:10 em 04/11/2026, pelo mesmo caminho** (a fatia 76d, a
CA3 de `docs/f3-relatorios/spec-carteira.md`): `relatoriosEvasaoAtualizar` saiu do código, a
produção continua chamando-a no código de lá até o corte, e os dois filtros a tiram do
crontab guardado **pelo nome da rotina**, e não pelo horário -- a do ticket médio,
`unidadesTicketMedioAtualizar`, roda nas mesmas 04:10 e fica. O
`scripts/ensaiar-crontab-do-passo-9.sh` e o `EvasaoSaiuTest` cobram as duas metades.

**Duas rotinas alcançáveis não estão aqui, e por motivos opostos.**

A segunda é `conveniosAtualizarVencimento`, e ela é **nossa**: criada em
28/08/2026 pelo commit `50cde07`, com teste (`ConvenioVencimentoTest`) e com
rota declarada. O que falta é a linha no crontab — e ela não foi acrescentada
porque a produção está congelada.

**Isso a torna código morto até alguém agendá-la**, e o passo 9 do
`procedimento-de-corte.md` não resolve sozinho: ele restaura
`/root/crontab-antes-do-corte.txt`, que é de antes de a rotina existir. Sem uma
linha nova, ela continua não rodando depois do corte também. O procedimento
passou a dizer isso.

**A linha já está escrita**, com horário e justificativa, em
`docs/crontab-a-aplicar.md` — e ela mora lá, e não nesta tabela, de propósito:
**esta tabela descreve o que roda hoje**, e é dela que
`scripts/conferir-agendamento.php` tira a verdade. Escrever aqui o que ainda não
foi aplicado deixaria a conferência verde sobre produção inalterada.

A primeira era `yungasNpsAtualizar`. Tinha rota e ninguém
a disparava — virou o **D43**, e em 31/08, por decisão sua, o método, a ação e a
rota foram **removidos**: o corpo tinha 27 linhas e as 27 estavam comentadas.

E o que eu havia chamado de "sete inalcançáveis" era erro meu: quatro estão sob
`/api/marketing/` e `/api/cupons/`, e as outras três não são cron, são endpoints
de consulta. Procurei num prefixo só.

---

## Três coisas que o crontab revelou

### 1. Duas entradas apontam para `www.` — e funcionam

`vencimentoPontuacaoProgramaFidelidade` (23:00) e `fidelizacaoAtualizar` (23:50)
chamam `https://www.franquiabv.com.br`; as outras 23 chamam o domínio raiz. Como
o `curl` está sem `-L`, um redirecionamento faria essas duas receberem o 301 e
pararem ali — sem erro, sem log de falha, e com o "Iniciando..." gravado
normalmente antes da chamada.

**Medido em 27/08/2026, e a suspeita caiu:**

| | |
|---|---|
| `https://www.franquiabv.com.br/` | **200** |
| `https://franquiabv.com.br/` | **200** |

Os dois nomes são servidos direto, sem redirecionamento entre eles. As duas
rotinas executam normalmente.

**O que fica:** a inconsistência é cosmética hoje e vira armadilha no dia em que
alguém padronizar os domínios — configurar o `www` para redirecionar ao raiz é
mudança comum, parece inofensiva, e desligaria em silêncio justamente as rotinas
de pontuação e fidelização.

Uniformizar as 25 entradas num domínio só custa uma edição e remove a armadilha.
Acrescentar `-L` ao `curl` resolveria também, e é menos bom: esconderia o
problema em vez de eliminá-lo.

### 2. O horário do dashboard mudou, e o D40 foi encerrado

O crontab agenda `relatoriosDashboardColaboradoresAtualizar` às **23:30**, numa
entrada só. As 107 linhas duplicadas do **D40** nasceram entre **23:00 e 23:05**.

A investigação seguiu até onde a evidência alcança:

| | |
|---|---|
| Registro do cron em 01/12/2025 | **uma execução**, às 23:00:01 |
| Requisições ao endpoint, na janela retida | **14, de um único IP** |
| Última duplicata criada | **16/12/2025** — oito meses atrás |

**A hipótese de duas execuções concorrentes caiu**, e a causa não será
confirmada: os logs do servidor web retêm cerca de catorze dias, e as duplicatas
são de 2023 e do fim de 2025.

O correlato mais plausível é a própria mudança de horário — de 23:00 para 23:30 —
que aconteceu em algum momento entre dezembro e agosto. É correlação, não prova.

**Encerrado no D40**, porque o efeito está protegido nos dois lados e o problema
não se manifesta há oito meses. O que fica valendo é operacional: a limpeza
pré-migração roda **imediatamente antes do dump**.

### 3. O token aparece 25 vezes, em texto claro

O mesmo token para todas as rotinas, escrito na URL de cada linha do crontab.
Está entre as oito credenciais em texto claro já registradas, mas vale a nota:
**na URL ele também vai para o log de acesso do servidor web**, que costuma ter
retenção e permissão mais frouxas que o código.

Trocar por cabeçalho (`curl -H`) tira o token da URL sem mudar nada mais.

**Desde 03/10/2026 a aplicação aceita o cabeçalho** (`Authorization: Bearer <token>`, D4), e a
query continua valendo -- as 25 linhas seguem funcionando como estão. E a recusa passou a ser
**401**: um crontab com o token errado não recebe mais `{"sucesso": true}`. Trocar as linhas, e
fazer o `curl` avisar do 401 (`-f`), é da produção: o **D331**. **Decidido em 06/10/2026** (lote de
perguntas, pergunta 5, a opção (b)): o passo 9 do `procedimento-de-corte.md` as troca para o
cabeçalho, com `-fsS` e `MAILTO`. **E sem e-mail desde 07/11/2026** (a resposta 128 (b) do dono; a
fatia 113): no lugar do `MAILTO`, cada linha termina em `|| logger -t rotinas "falhou (curl $?): <a
rota>"`, e o aviso fica no log do sistema (`journalctl -t rotinas`). Esta tabela continua dizendo o
que roda hoje.

**As duas rotinas dentro do banco** (os `EVENT` do **D26**) **viraram rotinas do código em
07/11/2026** (a fatia 113; as respostas 127 (a), 184 (a) e 195 (a)): `pushAppLimpar` e
`agendasExclusoesLimpar`. Elas não estão nesta tabela porque ainda não rodam: as linhas estão em
`docs/crontab-a-aplicar.md`, a espera do passo 12 da etapa 1 do corte, e o
`scripts/conferir-agendamento.php` as acusa como "com rota, sem crontab" -- conhecidas pelo nome no
`scripts/conferir-tudo.sh`, como a dos convênios.

---

## A trava que existe e quase ninguém usa

`CronJobService::acquireLock()` já implementa exclusão mútua com `flock`:

```php
$lock = $this->acquireLock("marketingEnvios");

if (!$lock) {
    return $this->jsonResponse(false, 'Processo já em execução.');
}
```

**Usada em 2 de 29 rotinas.** O D40 — 107 linhas duplicadas no dashboard, 84 com
valores divergentes — é exatamente o que acontece sem ela.

**O conserto do D40 já está no código**, e é aplicar o que já existe. Mas com
duas limitações que precisam ser ditas:

1. **É trava de arquivo, em `sys_get_temp_dir()`.** Protege contra dois processos
   na mesma máquina, e **não** contra duas máquinas. Com a F1.7 e mais de um nó,
   deixa de proteger.
2. **Trava não substitui restrição de unicidade.** A do dashboard já entrou no
   schema PostgreSQL; a de royalties ainda não tem.

Para a F1.7, a trava precisa sair do disco e ir para o banco — `pg_advisory_lock`
resolve, e vale para todos os nós.

---

## O que muda no PostgreSQL

Os dois `EVENT` do MySQL **não têm equivalente** no PostgreSQL:

| Evento | Frequência | O que faz | Estado |
|---|---|---|---|
| `limpar_push_app_24h` | 1 hora | apaga `push_app` com mais de 24h | **ativo** |
| `evt_agendas_exclusoes_limpar` | 1 dia | apaga `agendas_exclusoes` com mais de 60 dias | **`DISABLE ON SLAVE`, nunca rodou** |

Os dois são limpeza de retenção, e os dois declaram `time_zone = '-03:00'` — o
mesmo fuso medido na réplica em 27/08 (ver D42).

Duas saídas, e a segunda é a recomendada:

**`pg_cron`.** Mantém o agendamento dentro do banco, como hoje. Exige extensão
instalada no servidor, e num serviço gerenciado isso pode não estar disponível.

**Agendador externo, junto com os outros.** As 22 rotinas já são disparadas de
fora; trazer estas duas para o mesmo lugar deixa **um só inventário de
agendamento** em vez de dois. É a única forma de responder "o que roda neste
sistema?" olhando um lugar só — que é o problema que esta frente existe para
resolver.

O segundo evento nunca rodou, então migrar é decidir se ele **deve** rodar. Se
sim, `agendas_exclusoes` tem retenção acumulada desde sempre e a primeira
execução vai apagar bastante — vale medir antes.

---

## O que ainda depende do servidor

Uma coisa, e é a que fecha o portão:

**Ler o crontab de produção.** O código diz o que cada rotina assume; só o
crontab diz o que de fato é disparado, com que frequência, e se alguma das 22
está fora dele.

```bash
crontab -l
sudo crontab -l -u www-data
ls -la /etc/cron.d/
```

Vale conferir junto, e é a pergunta que originou o D40:

```bash
grep -c relatoriosDashboardColaboradoresAtualizar /etc/cron.d/* 2>/dev/null
```

Se aparecer mais de uma vez, ou em mais de uma máquina, a duplicação do dashboard
tem causa confirmada em vez de inferida.

---

## Portão de saída: ATINGIDO em 27/08/2026

As três perguntas, respondidas:

| | |
|---|---|
| **Quem dispara** | 25 entradas de crontab via `curl`, mais 2 rotinas dentro do banco como `EVENT` |
| **Com que frequência** | tabela acima, do crontab de produção — não mais inferida |
| **O que acontece se falhar** | tabela das rotinas, por rotina |

Fica registrado o que o inventário **encontrou** e ainda espera decisão, porque
inventariar sem decidir não resolve:

| | |
|---|---|
| `yungasNpsAtualizar` alcançável e nunca agendada | **D43** — resolvido em 31/08 por remoção |
| Duas entradas apontando para `www.`, que pode não responder | verificação de um comando |
| O horário do dashboard não bater com o D40 | os registros respondem |
| A trava usada em 2 de 29 rotinas, e sendo de arquivo | **D44** |
| Os dois `EVENT` sem equivalente no PostgreSQL | decisão entre `pg_cron` e agendador externo |

Nenhum deles bloqueia a fase. Todos são pequenos e todos têm dono declarado, que
era exatamente o que o portão pedia.
