teste #10
158
README.md
158
README.md
@@ -1452,13 +1452,47 @@ feito manualmente para entregar ao cliente.
|
|||||||
ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em
|
ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em
|
||||||
`002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
|
`002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
|
||||||
itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
|
itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
|
||||||
|
- **Links das fotos são hiperlinks clicáveis** (azul sublinhado): Nota Fiscal, Termo de
|
||||||
|
Recebimento, Foto da Fachada e Relatório de visita abrem o comprovante direto do Excel.
|
||||||
|
Qualquer célula que seja URL `http(s)` vira link (`#linkar_urls`).
|
||||||
- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
|
- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
|
||||||
caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.
|
caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.
|
||||||
- ⚠️ **Ressalvas:** nomes de coluna das tabelas `gade_entregas_*`/rastreio foram deduzidos com
|
|
||||||
fallback seguro (coluna inexistente sai **vazia**, sem quebrar o layout) — conferir o primeiro
|
#### Nomes de coluna — conferidos no banco real (22/07/2026)
|
||||||
download real e ajustar `COLUNAS_GADE`/`RASTREIO_COLUNAS` se algo sair em branco. Os 2
|
Os nomes eram **deduzidos** e 9 cabeçalhos nunca casavam, saindo vazios em silêncio. Os nomes
|
||||||
**gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é controle
|
reais foram conferidos com `Entrega.column_names` e estão como 1º candidato em
|
||||||
manual (sai "A iniciar").
|
`RASTREIO_COLUNAS`; os chutes antigos continuam na lista como rede de segurança.
|
||||||
|
- Espelho usa o padrão do export pt-BR: `codrivers`, `responsible_person`, `advance`,
|
||||||
|
`start_of_time_window_1..2`, `end_of_time_window_1..2`, `required_skills`, `optional_skills`,
|
||||||
|
`comments`. Corrigidos — antes eram `copilots`, `receiver`, `early`, `window_start`, etc.
|
||||||
|
- Tabelas `gade_entregas_*` gravam a geo como **`lat`/`long`** (não `latitude`/`longitude`) —
|
||||||
|
as duas colunas da aba ENTREGAS saíam vazias. Ver `ALIAS_GADE`.
|
||||||
|
- **`Load 4` não existe** no espelho (só `load`, `load_2`, `load_3`) — sai vazia de propósito.
|
||||||
|
- Coluna **`protocolo_de_entrega`** existe no espelho e não estava em lugar nenhum: entrou como
|
||||||
|
última coluna (AU), **depois** do layout A–AT do modelo, para não deslocar nada do cliente.
|
||||||
|
- ⚠️ Vazias por **falta de dado na origem**, não por bug: `contact_phone`, `account_id`,
|
||||||
|
`account_name` (colunas existem no espelho e o sync nunca preenche — a API do SimpliRoute
|
||||||
|
TEM o valor, conferido na NF 79093) e `codrivers`/`required_skills`/`optional_skills`/
|
||||||
|
`contact_email`/`load_2`/`load_3` (vazios também no relatório baixado direto do SimpliRoute).
|
||||||
|
- ⚠️ Os 2 **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é
|
||||||
|
controle manual (sai "A iniciar").
|
||||||
|
|
||||||
|
#### 🔄 Número de série da base (aparelho) — job em background
|
||||||
|
A coluna AT da aba SimpliRoute é o único campo do modelo que o espelho **nunca** traz (0 de
|
||||||
|
~6.000 linhas em fev/mar/mai/jul/2026), e não dá para deduzir da `gade_entregas_*`: os seriais
|
||||||
|
de `num_serie_base` são de NFs **disjuntas** das que têm série no SimpliRoute (0 de 203 batem).
|
||||||
|
O dado só existe na API, em `extra_field_values` — o mesmo hash das fotos do motorista.
|
||||||
|
- `SincronizarSeriesAparelhoJob` varre a API por DATA (a API não filtra intervalo; cada dia são
|
||||||
|
~4 MB / ~9 s) em lotes de 6 threads e grava em **`series_aparelho`** (tabela nossa — o espelho
|
||||||
|
é read-only). Um dia que não responde vira aviso, não derruba a rodada.
|
||||||
|
- `Analytics::PlanilhaEntregas#completar_serie` completa **só** as linhas em que o espelho veio
|
||||||
|
vazio: se um dia o sync passar a preencher, o valor do espelho continua ganhando.
|
||||||
|
- Agendado em `config/schedule.rb` às **01h** (últimos 45 dias). Backfill de meses antigos:
|
||||||
|
`rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"` ou
|
||||||
|
`rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"`.
|
||||||
|
- Se a rodada varrer visitas e não achar **nenhuma** série, o log lista as chaves de
|
||||||
|
`extra_field_values` que vieram — é o sinal de que o campo mudou de nome e basta acrescentá-lo
|
||||||
|
em `CAMPOS_SERIE` (foi chute de nome de campo que causou o bug acima).
|
||||||
|
|
||||||
### 📊 Dashboard financeiro
|
### 📊 Dashboard financeiro
|
||||||
- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
|
- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
|
||||||
@@ -1638,3 +1672,117 @@ app/javascript/controllers/validacao_controller.js (chip usa SVG, não emoji)
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>🔁 NF com mais de um lançamento + varredura de responsividade (21–22/07/2026)</strong></summary>
|
||||||
|
|
||||||
|
## Parte 1 — Editar Lançamento: quando a mesma NF tem 2 visitas
|
||||||
|
|
||||||
|
### 🎯 O problema real
|
||||||
|
Quando um plano é **duplicado** no SimpliRoute, nasce uma **visita nova** (outro `tracking_id`) com a
|
||||||
|
**mesma NF**, e a antiga continua existindo. Caso que motivou tudo: **NF 82891** com visita em
|
||||||
|
**17/07** (pendente, motorista Thiago Rabello Bittencourt) e outra em **21/07** (sucesso, sem
|
||||||
|
motorista). A tela mostrava **uma só** — e depois, por regressão, **nenhuma**.
|
||||||
|
|
||||||
|
### 🔴 Causas corrigidas (foram 7, em camadas)
|
||||||
|
1. **`Entrega.por_nf(nf).first`** — pegava uma linha só, sem `ORDER BY`. Agora carrega todas e a
|
||||||
|
tela lista as ocorrências para o ADM escolher qual editar.
|
||||||
|
2. **`SimpliRoute::Client#resolver_id` abortava na ambiguidade** (`"Mais de uma visita para a NF…"`)
|
||||||
|
em vez de deixar escolher. A tela de edição não passa mais por ele.
|
||||||
|
3. **As datas vinham só do espelho local.** Como a API **não busca NF por intervalo**, a visita de
|
||||||
|
17/07 era inalcançável se o painel só conhecia a de 21/07. Entraram campos **De/Até** + botão
|
||||||
|
**"Últimos 30 dias"** (varredura dia a dia, teto de 62 dias).
|
||||||
|
4. **Um dia com falha derrubava a busca inteira** → agora é *fail-soft* por dia: registra em
|
||||||
|
`falhas` e segue. A tela informa o período consultado e quais dias falharam.
|
||||||
|
5. **`carregar?` faltando em `EdicaoLancamentoPolicy`** → o Pundit levantava `NoMethodError` (500),
|
||||||
|
o `fetch` recebia HTML e o JS reportava "erro de conexão". Falhou **fechado** (negou acesso),
|
||||||
|
sem brecha de segurança.
|
||||||
|
6. **Tela em branco:** `renderOcorrencias()` montava a lista inteira mas **nunca removia a classe
|
||||||
|
`hidden`** do container. O conteúdo estava no DOM (contador já dizia "2 lançamentos"), invisível.
|
||||||
|
7. **Fuso horário:** `new Date('2026-07-15')` é meia-noite **UTC** e, em UTC−3, o `toLocale`
|
||||||
|
devolvia **14/07**. Datas puras agora são formatadas direto do texto ISO; horários de checkout
|
||||||
|
continuam convertendo (correto para instante).
|
||||||
|
|
||||||
|
### 🔑 Descobertas sobre a API do SimpliRoute (medidas contra a API real, 21/07/2026)
|
||||||
|
| Parâmetro | Resultado |
|
||||||
|
|---|---|
|
||||||
|
| **`&search=<NF>`** | ✅ **Funciona e não é documentado.** Um dia cai de **3,8 MB / ~9 s** (1879 visitas) para **~1 KB / ~0,6 s**. Varredura de 31 dias em lotes paralelos: **3,4 s / 3,2 KB** |
|
||||||
|
| `reference`, `reference_id`, `q`, `title`, `reference__*` | ❌ **Ignorados em silêncio** — devolvem 200 com o dia inteiro |
|
||||||
|
| `planned_date_from/to`, `since/until`, `__gte/__lte`, `date_from/to` | ❌ **Não existe filtro de intervalo** — todos devolveram 2469, idêntico ao controle **sem parâmetro nenhum** |
|
||||||
|
| `GET /v1/routes/visits/` sem `planned_date` | ⚠️ Devolve um **conjunto padrão (~2469)** que **não cobre o histórico** — para a NF 82891 voltava só a visita de 21/07 e escondia a de 17/07 |
|
||||||
|
|
||||||
|
> ⚠️ **Lição:** parâmetro não registrado no backend Django é **ignorado sem erro**. "Voltou 200 com
|
||||||
|
> resultados" **não** prova que filtrou — a prova é a **contagem diminuir** em relação ao dia sem o
|
||||||
|
> filtro. Ferramenta: **`bin/sondar_busca_nf --nf <n> --data <YYYY-MM-DD> [--sem-data]`** (só GET).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parte 2 — Responsividade
|
||||||
|
|
||||||
|
### 🎯 A raiz comum
|
||||||
|
Os breakpoints do Tailwind (`md:`, `lg:`, `xl:`) enxergam a **janela**, mas o conteúdo perde
|
||||||
|
**256px** para a sidebar (`main.md:ml-64`). **A mesma janela de 1280px tem duas larguras** conforme
|
||||||
|
o menu esteja aberto ou recolhido — e grades de colunas fixas não ficam sabendo. Daí "quando a barra
|
||||||
|
de menu é acionada, quebra o layout". Solução: **`flex-wrap` / `auto-fit + minmax`**, que reagem ao
|
||||||
|
espaço **real**.
|
||||||
|
|
||||||
|
### 🔧 Corrigido
|
||||||
|
| Tela | Antes | Sintoma | Depois |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `consolidacoes/index` | `md:grid-cols-6` | Campos a ~150px; texto do botão **"Filtrar" vazava** para fora do fundo | `flex-wrap` + `basis-*` |
|
||||||
|
| `dashboard/index` | `xl:grid-cols-5` | **"R$ 82.692,"** — valor cortado pelo `overflow-hidden` do card | `auto-fit,minmax(13rem,1fr)` |
|
||||||
|
| `consolidacao_entregas/revisar` | `md:grid-cols-7` | 7 cards de **~55px**, destruindo "Extraordinária"/"Termo Especial" | `auto-fit,minmax(9rem,1fr)` |
|
||||||
|
| `configuracoes` + `admin/configuracoes` | `lg:grid-cols-4` | ~128px úteis para valores em moeda | `auto-fit,minmax(14rem,1fr)` |
|
||||||
|
|
||||||
|
**Mantidos de propósito:** `validar.html.erb` (`lg:grid-cols-4` é a divisão 3:1 lista/Resumo, não
|
||||||
|
grade de cards) e os `xl:grid-cols-3` de gráficos — nesses o conteúdo encolhe sem cortar.
|
||||||
|
|
||||||
|
### 📐 Passo 2 (validar) — cabeçalho fixo
|
||||||
|
Medido a 1280×577: **442px de cabeçalho contra 67px de lista** (menos que um card).
|
||||||
|
|
||||||
|
- Voltar + título + progresso passaram a **uma faixa só**; o card de progresso (70px de moldura para
|
||||||
|
uma barra de 12px) virou linha de 8px.
|
||||||
|
- **"Adicionar lançamento"** entrou na barra de ações via `order` do flex — sem mover os ~170 linhas
|
||||||
|
de modais que vivem dentro do `data-controller`.
|
||||||
|
- **Pilares (Normal/Retirada/Bônus/Desconto/Extra) continuam SEMPRE visíveis**, apenas **esmaecidos**
|
||||||
|
(`opacity-40`) enquanto não há seleção, com contador e "limpar" aparecendo ao selecionar.
|
||||||
|
⚠️ Uma versão intermediária os **escondia** até haver seleção — revertido: são a ação principal da
|
||||||
|
tela e sumir com eles esconde o que dá para fazer de quem ainda não sabe que precisa selecionar.
|
||||||
|
- Legibilidade preservada: título `text-2xl`, subtítulo e progresso `text-sm`, números em branco/negrito.
|
||||||
|
|
||||||
|
**Resultado:** cabeçalho **442px → 210px**, lista **67px → 299px**.
|
||||||
|
|
||||||
|
### 📂 Arquivos
|
||||||
|
```
|
||||||
|
app/controllers/admin/edicao_lancamentos_controller.rb # lista ocorrências, período, fail-soft, carregar
|
||||||
|
app/policies/edicao_lancamento_policy.rb # + carregar? (era o 500)
|
||||||
|
app/services/simpli_route/client.rb # visitas_da_data(data, busca:) → &search=
|
||||||
|
app/views/admin/edicao_lancamentos/show.html.erb # lista de ocorrências, De/Até, avisos, fuso
|
||||||
|
config/routes.rb # + get :carregar
|
||||||
|
bin/sondar_busca_nf # NOVO — sondagem de filtros da API (só GET)
|
||||||
|
app/views/consolidacao_entregas/validar.html.erb # cabeçalho compacto + barra de ações
|
||||||
|
app/javascript/controllers/validacao_controller.js # atualizarSelecao / limparSelecao
|
||||||
|
app/views/consolidacao_entregas/revisar.html.erb # grid-cols-7 → auto-fit
|
||||||
|
app/views/consolidacoes/index.html.erb # filtros → flex-wrap
|
||||||
|
app/views/dashboard/index.html.erb # KPIs → auto-fit
|
||||||
|
app/views/configuracoes/index.html.erb # preços → auto-fit
|
||||||
|
app/views/admin/configuracoes/index.html.erb # idem (arquivo distinto, também vivo)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Pendências e alertas
|
||||||
|
- **Tailwind vem do Play CDN** (`cdn.tailwindcss.com`, em `application.html.erb:15`), que gera CSS no
|
||||||
|
navegador em tempo real. A documentação oficial **desaconselha em produção** (~380KB bloqueando a
|
||||||
|
renderização, recompilação a cada carregamento). O **`tailwind.config.js` do repositório não está
|
||||||
|
sendo usado** — a config real está embutida no layout (linha 17), e a gem `tailwindcss-rails` está
|
||||||
|
no Gemfile sem servir CSS.
|
||||||
|
- **Não existe teste para `Admin::EdicaoLancamentosController`** (`spec/` não tem nada de
|
||||||
|
`edicao_lancamento`). Duas das quebras acima — policy faltando e `hidden` não removido — seriam
|
||||||
|
pegas por um teste de request/sistema em segundos, sem custar deploy.
|
||||||
|
- **Token do SimpliRoute:** se passou por chat/e-mail, **rotacione** em `app2.simpliroute.com`.
|
||||||
|
|
||||||
|
> **Sem migration e sem gem nova** — controller, policy, service, views e JS.
|
||||||
|
> ⚠️ Reiniciar o Puma após o deploy (cache de classes/views).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -22,45 +22,43 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
|||||||
@motivos = motivos_seguros
|
@motivos = motivos_seguros
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com o estado atual.
|
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com TODOS os lançamentos
|
||||||
|
# daquela NF.
|
||||||
|
#
|
||||||
|
# ⚠️ Uma NF pode ter mais de um lançamento: quando o plano é duplicado no
|
||||||
|
# SimpliRoute nasce uma visita nova (outro tracking_id) com a mesma NF, e a
|
||||||
|
# antiga — a que tem o motorista/checkout — continua existindo. Por isso a
|
||||||
|
# busca devolve a LISTA e o ADM escolhe qual editar; assumir "1 NF = 1
|
||||||
|
# lançamento" escondia justamente o lançamento antigo.
|
||||||
|
#
|
||||||
|
# Com um único lançamento já vem o card montado, para não custar um segundo
|
||||||
|
# round-trip no caso comum.
|
||||||
|
#
|
||||||
|
# Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então
|
||||||
|
# varremos dia a dia). Sem período, usa as datas que o espelho conhece.
|
||||||
def buscar
|
def buscar
|
||||||
authorize :edicao_lancamento
|
authorize :edicao_lancamento
|
||||||
|
|
||||||
nf = params[:nf].to_s.strip
|
nf = params[:nf].to_s.strip
|
||||||
return render_erro('Informe o número da NF.') if nf.blank?
|
return render_erro('Informe o número da NF.') if nf.blank?
|
||||||
|
|
||||||
entrega = Entrega.por_nf(nf).first
|
entregas = Entrega.por_nf(nf).to_a
|
||||||
return render_erro("NF #{nf} não encontrada na base de rastreio.") if entrega.nil?
|
datas = datas_de_busca(entregas)
|
||||||
|
return render_erro(sem_datas_msg(nf, entregas)) if datas.empty?
|
||||||
|
|
||||||
id = client.resolver_id(entrega)
|
falhas = []
|
||||||
visita = client.visita(id)
|
ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas)
|
||||||
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
|
unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present?
|
||||||
detalhe = client.detalhe_visita(id)
|
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
ok: true,
|
ok: true,
|
||||||
visita: {
|
ocorrencias: ocorrencias,
|
||||||
id: id,
|
visita: (unica && payload_seguro(unica, entregas, falhas)),
|
||||||
nf: visita['reference'],
|
periodo: { de: datas.first, ate: datas.last, dias: datas.size },
|
||||||
titulo: visita['title'],
|
# Dias que a API não respondeu. Vão para a tela: uma busca que devolve
|
||||||
endereco: visita['address'],
|
# menos do que existe TEM que dizer que devolveu menos.
|
||||||
status: visita['status'],
|
falhas: falhas,
|
||||||
checkout_observation: visita['checkout_observation'],
|
motivos: motivos_seguros
|
||||||
checkout_comment: visita['checkout_comment'],
|
|
||||||
notes: visita['notes'],
|
|
||||||
checkout_time: visita['checkout_time'],
|
|
||||||
checkout_latitude: visita['checkout_latitude'],
|
|
||||||
checkout_longitude: visita['checkout_longitude'],
|
|
||||||
planned_date: visita['planned_date'],
|
|
||||||
contato: visita['contact_name'],
|
|
||||||
telefone: visita['contact_phone'],
|
|
||||||
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
|
|
||||||
# cada uma etiquetada para o ADM saber o que está conferindo.
|
|
||||||
fotos: fotos_do_lancamento(entrega, visita, detalhe),
|
|
||||||
motorista: entrega.driver,
|
|
||||||
veiculo: entrega.vehicle
|
|
||||||
},
|
|
||||||
motivos: motivos_seguros
|
|
||||||
}
|
}
|
||||||
rescue SimpliRoute::NotFound => e
|
rescue SimpliRoute::NotFound => e
|
||||||
render_erro(e.message)
|
render_erro(e.message)
|
||||||
@@ -68,6 +66,23 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
|||||||
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
|
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /admin/edicao_lancamento/carregar?visit_id=871407488&tracking_id=SR...
|
||||||
|
# Monta o card de UM lançamento escolhido na lista de ocorrências.
|
||||||
|
def carregar
|
||||||
|
authorize :edicao_lancamento
|
||||||
|
|
||||||
|
id = params[:visit_id].to_s.strip
|
||||||
|
return render_erro('Lançamento não identificado.') if id.blank?
|
||||||
|
|
||||||
|
entrega = Entrega.find_by(tracking_id: params[:tracking_id]) if params[:tracking_id].present?
|
||||||
|
|
||||||
|
render json: { ok: true, visita: payload_visita(id, entrega), motivos: motivos_seguros }
|
||||||
|
rescue SimpliRoute::NotFound => e
|
||||||
|
render_erro(e.message)
|
||||||
|
rescue SimpliRoute::Error => e
|
||||||
|
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
|
||||||
|
end
|
||||||
|
|
||||||
# PATCH /admin/edicao_lancamento/atualizar
|
# PATCH /admin/edicao_lancamento/atualizar
|
||||||
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
|
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
|
||||||
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
|
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
|
||||||
@@ -146,6 +161,210 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
|||||||
@client ||= SimpliRoute::Client.new
|
@client ||= SimpliRoute::Client.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Máximo de dias varridos numa busca por período. A API só filtra por UM dia,
|
||||||
|
# então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão
|
||||||
|
# em lotes paralelos, o que torna dois meses viável; o teto existe para a tela
|
||||||
|
# não virar uma varredura sem fim.
|
||||||
|
MAX_DIAS_VARREDURA = 62
|
||||||
|
|
||||||
|
# Quais dias consultar na API:
|
||||||
|
# • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para
|
||||||
|
# outra data, e aí o espelho não tem como saber qual é);
|
||||||
|
# • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas).
|
||||||
|
def datas_de_busca(entregas)
|
||||||
|
de = data_param(:de)
|
||||||
|
ate = data_param(:ate)
|
||||||
|
|
||||||
|
if de || ate
|
||||||
|
inicio, fim = [de || ate, ate || de].minmax
|
||||||
|
(inicio..fim).first(MAX_DIAS_VARREDURA)
|
||||||
|
else
|
||||||
|
entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def data_param(chave)
|
||||||
|
valor = params[chave].to_s.strip
|
||||||
|
return nil if valor.blank?
|
||||||
|
|
||||||
|
Date.parse(valor)
|
||||||
|
rescue Date::Error
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sem datas não há o que consultar — e o motivo muda a saída para o ADM.
|
||||||
|
def sem_datas_msg(nf, entregas)
|
||||||
|
if entregas.empty?
|
||||||
|
"NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute."
|
||||||
|
else
|
||||||
|
"NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Todos os lançamentos da NF, cruzando as DUAS fontes:
|
||||||
|
# • espelho de rastreio (Entrega) — traz motorista/veículo;
|
||||||
|
# • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a
|
||||||
|
# sincronização ainda não trouxe para o espelho.
|
||||||
|
def ocorrencias_da_nf(nf, entregas, datas, falhas)
|
||||||
|
trackings = entregas.map { |e| e.tracking_id.to_s }
|
||||||
|
do_dia = visitas_dos_dias(datas, nf, falhas)
|
||||||
|
|
||||||
|
visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) }
|
||||||
|
.uniq { |v| v['id'] }
|
||||||
|
visitas = com_tracking_id(visitas)
|
||||||
|
|
||||||
|
achadas = visitas.map { |v| ocorrencia_da_api(v, entrega_de(entregas, v['tracking_id'])) }
|
||||||
|
|
||||||
|
# Lançamento que existe no espelho mas não apareceu na API (data sem plano,
|
||||||
|
# visita removida): entra na lista como não editável, para o ADM ver que
|
||||||
|
# ele existe em vez de sumir silenciosamente — que é o bug que estamos
|
||||||
|
# corrigindo.
|
||||||
|
vistos = visitas.map { |v| v['tracking_id'].to_s }
|
||||||
|
orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }
|
||||||
|
.map { |e| ocorrencia_do_espelho(e, datas.include?(e.planned_date&.to_date)) }
|
||||||
|
|
||||||
|
# Mais recente primeiro (a data vem como texto ISO da API e como timestamp
|
||||||
|
# do espelho — os 10 primeiros caracteres normalizam as duas).
|
||||||
|
(achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver
|
||||||
|
# SimpliRoute::Client#requisicao) e não toca o banco, então dá para
|
||||||
|
# paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute.
|
||||||
|
MAX_PARALELO_VARREDURA = 6
|
||||||
|
|
||||||
|
# Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro)
|
||||||
|
# e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data.
|
||||||
|
def visitas_dos_dias(datas, nf, falhas)
|
||||||
|
mutex = Mutex.new
|
||||||
|
|
||||||
|
datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote|
|
||||||
|
lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a
|
||||||
|
# falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas
|
||||||
|
# datas passou a não devolver nada em vez de devolver o que deu certo.)
|
||||||
|
def visitas_do_dia(data, nf, falhas, mutex)
|
||||||
|
client.visitas_da_data(data, busca: nf)
|
||||||
|
rescue SimpliRoute::Error => e
|
||||||
|
mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } }
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Abrir o card do lançamento único é conveniência: se falhar, a lista ainda
|
||||||
|
# tem que aparecer.
|
||||||
|
def payload_seguro(ocorrencia, entregas, falhas)
|
||||||
|
payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id]))
|
||||||
|
rescue SimpliRoute::Error => e
|
||||||
|
falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def entrega_de(entregas, tracking_id)
|
||||||
|
return nil if tracking_id.blank?
|
||||||
|
|
||||||
|
entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Quantas visitas da lista vale a pena detalhar (1 GET cada). Uma NF com mais
|
||||||
|
# que isso é dado estranho, não plano duplicado.
|
||||||
|
MAX_DETALHES_LISTA = 5
|
||||||
|
|
||||||
|
# A lista-por-data nem sempre traz o `tracking_id`, e é ele que liga a visita
|
||||||
|
# ao espelho — de onde vêm motorista e veículo. Sem esse casamento a NF
|
||||||
|
# duplicada aparece com as duas linhas sem motorista, que é exatamente o dado
|
||||||
|
# que distingue uma da outra. Quando faltar, busca a visita completa.
|
||||||
|
def com_tracking_id(visitas)
|
||||||
|
visitas.each_with_index.map do |visita, i|
|
||||||
|
next visita if visita['tracking_id'].present? || i >= MAX_DETALHES_LISTA
|
||||||
|
|
||||||
|
detalhar(visita)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Best-effort: se o detalhe falhar, segue com o que a lista deu.
|
||||||
|
def detalhar(visita)
|
||||||
|
client.visita(visita['id']).presence || visita
|
||||||
|
rescue SimpliRoute::Error
|
||||||
|
visita
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resumo de um lançamento para a lista de escolha (não carrega fotos/detalhe —
|
||||||
|
# isso só acontece quando o ADM abre um).
|
||||||
|
def ocorrencia_da_api(visita, entrega)
|
||||||
|
{
|
||||||
|
id: visita['id'],
|
||||||
|
tracking_id: visita['tracking_id'],
|
||||||
|
titulo: visita['title'],
|
||||||
|
endereco: visita['address'],
|
||||||
|
status: visita['status'],
|
||||||
|
data: visita['planned_date'],
|
||||||
|
checkout_time: visita['checkout_time'],
|
||||||
|
motorista: entrega&.driver,
|
||||||
|
veiculo: entrega&.vehicle,
|
||||||
|
editavel: true,
|
||||||
|
# Sem linha no espelho não há como saber motorista/veículo: a API não
|
||||||
|
# devolve esses campos na visita. O ADM diferencia pelo status/checkout.
|
||||||
|
no_painel: entrega.present?,
|
||||||
|
aviso: entrega ? nil : 'Ainda não sincronizado no painel — motorista e veículo indisponíveis'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def ocorrencia_do_espelho(entrega, data_varrida)
|
||||||
|
{
|
||||||
|
id: nil,
|
||||||
|
tracking_id: entrega.tracking_id,
|
||||||
|
titulo: entrega.local,
|
||||||
|
endereco: entrega.address,
|
||||||
|
status: entrega.status,
|
||||||
|
data: entrega.planned_date,
|
||||||
|
checkout_time: entrega.checkout,
|
||||||
|
motorista: entrega.driver,
|
||||||
|
veiculo: entrega.vehicle,
|
||||||
|
editavel: false,
|
||||||
|
no_painel: true,
|
||||||
|
aviso: (if data_varrida
|
||||||
|
'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar'
|
||||||
|
else
|
||||||
|
'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo'
|
||||||
|
end)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Estado completo de UMA visita para o card (inclui a galeria de fotos, que
|
||||||
|
# custa uma chamada extra ao POD). `entrega` pode ser nil quando a visita
|
||||||
|
# ainda não sincronizou no espelho.
|
||||||
|
def payload_visita(id, entrega)
|
||||||
|
visita = client.visita(id)
|
||||||
|
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
|
||||||
|
detalhe = client.detalhe_visita(id)
|
||||||
|
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
tracking_id: visita['tracking_id'],
|
||||||
|
nf: visita['reference'],
|
||||||
|
titulo: visita['title'],
|
||||||
|
endereco: visita['address'],
|
||||||
|
status: visita['status'],
|
||||||
|
checkout_observation: visita['checkout_observation'],
|
||||||
|
checkout_comment: visita['checkout_comment'],
|
||||||
|
notes: visita['notes'],
|
||||||
|
checkout_time: visita['checkout_time'],
|
||||||
|
checkout_latitude: visita['checkout_latitude'],
|
||||||
|
checkout_longitude: visita['checkout_longitude'],
|
||||||
|
planned_date: visita['planned_date'],
|
||||||
|
contato: visita['contact_name'],
|
||||||
|
telefone: visita['contact_phone'],
|
||||||
|
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
|
||||||
|
# cada uma etiquetada para o ADM saber o que está conferindo.
|
||||||
|
fotos: fotos_do_lancamento(entrega, visita, detalhe),
|
||||||
|
motorista: entrega&.driver,
|
||||||
|
veiculo: entrega&.vehicle
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
# Monta o hash de PATCH só com os campos que vieram no form E que realmente
|
# Monta o hash de PATCH só com os campos que vieram no form E que realmente
|
||||||
# mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
|
# mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
|
||||||
def mudancas(anterior)
|
def mudancas(anterior)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Controller } from "@hotwired/stimulus"
|
|||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [
|
static targets = [
|
||||||
"linha", "checkbox", "selecionarTodos", "busca", "buscaVazia",
|
"linha", "checkbox", "selecionarTodos", "busca", "buscaVazia",
|
||||||
|
"grupoPilares", "chipSelecao", "contadorSelecao", "btnLimpar",
|
||||||
"resumo", "listaCol", "btnResumoLabel",
|
"resumo", "listaCol", "btnResumoLabel",
|
||||||
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
|
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
|
||||||
"consolidarBtn", "consolidarHint",
|
"consolidarBtn", "consolidarHint",
|
||||||
@@ -98,6 +99,32 @@ export default class extends Controller {
|
|||||||
if (linha && linha.classList.contains("hidden")) return
|
if (linha && linha.classList.contains("hidden")) return
|
||||||
c.checked = marcar
|
c.checked = marcar
|
||||||
})
|
})
|
||||||
|
this.atualizarSelecao()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Estado da seleção ──
|
||||||
|
// Os pilares ficam SEMPRE na tela; sem seleção apenas esmaecem. Esconder e
|
||||||
|
// remostrar mexia na altura do cabeçalho a cada clique e escondia a ação
|
||||||
|
// principal justamente de quem ainda não sabe que precisa selecionar antes.
|
||||||
|
atualizarSelecao() {
|
||||||
|
const n = this.checkboxTargets.filter(c => c.checked).length
|
||||||
|
|
||||||
|
if (this.hasContadorSelecaoTarget) this.contadorSelecaoTarget.textContent = n
|
||||||
|
if (this.hasChipSelecaoTarget) this.chipSelecaoTarget.classList.toggle("hidden", n === 0)
|
||||||
|
if (this.hasBtnLimparTarget) this.btnLimparTarget.classList.toggle("hidden", n === 0)
|
||||||
|
|
||||||
|
if (this.hasGrupoPilaresTarget) {
|
||||||
|
this.grupoPilaresTarget.classList.toggle("opacity-40", n === 0)
|
||||||
|
this.grupoPilaresTarget.title = n === 0
|
||||||
|
? "Marque ao menos uma entrega para aplicar em massa"
|
||||||
|
: `Aplicar em ${n} entrega(s) — clique de novo no pilar para remover`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limparSelecao() {
|
||||||
|
this.checkboxTargets.forEach(c => (c.checked = false))
|
||||||
|
if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false
|
||||||
|
this.atualizarSelecao()
|
||||||
}
|
}
|
||||||
|
|
||||||
// O pilar está ativo (ring-2) na entrega indicada?
|
// O pilar está ativo (ring-2) na entrega indicada?
|
||||||
@@ -137,8 +164,7 @@ export default class extends Controller {
|
|||||||
valor: valor
|
valor: valor
|
||||||
})
|
})
|
||||||
ids.forEach(id => this.setToggle(id, tipo, !remover))
|
ids.forEach(id => this.setToggle(id, tipo, !remover))
|
||||||
this.checkboxTargets.forEach(c => c.checked = false)
|
this.limparSelecao() // some a barra de ações junto com a seleção
|
||||||
if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false
|
|
||||||
this.atualizarResumo(resp)
|
this.atualizarResumo(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
149
app/jobs/sincronizar_series_aparelho_job.rb
Normal file
149
app/jobs/sincronizar_series_aparelho_job.rb
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# app/jobs/sincronizar_series_aparelho_job.rb
|
||||||
|
#
|
||||||
|
# Busca na API do SimpliRoute o "Número de Série da Base (Aparelho)" das visitas
|
||||||
|
# e grava em series_aparelho, para a planilha Entregas do cliente sair com essa
|
||||||
|
# coluna preenchida.
|
||||||
|
#
|
||||||
|
# POR QUE EM BACKGROUND: o dado só existe na API (o espelho tem a coluna e nunca
|
||||||
|
# a preenche — 0 de ~6.000 linhas em fev/mar/mai/jul/2026). A API não filtra por
|
||||||
|
# intervalo: é uma chamada POR DATA, cada uma com o dia inteiro (~4 MB / ~9 s).
|
||||||
|
# Um mês são ~25 chamadas — inviável dentro do request do download, tranquilo
|
||||||
|
# num job noturno.
|
||||||
|
#
|
||||||
|
# Roda via Whenever → `rake simpli_route:series` (últimos DIAS_PADRAO dias).
|
||||||
|
# Para meses antigos, o backfill é manual: `rake simpli_route:series_operacao[tabela]`.
|
||||||
|
class SincronizarSeriesAparelhoJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Mesmo limite da varredura do Admin::EdicaoLancamentosController — a API do
|
||||||
|
# SimpliRoute é lenta por dia, mas aguenta alguns dias em paralelo.
|
||||||
|
MAX_PARALELO = 6
|
||||||
|
|
||||||
|
# Janela padrão da rodada agendada. Cobre o mês corrente inteiro com folga,
|
||||||
|
# que é o que o cliente pede em planilha no dia a dia.
|
||||||
|
DIAS_PADRAO = 45
|
||||||
|
|
||||||
|
# Nomes conhecidos do campo em `extra_field_values`. O 1º que vier preenchido
|
||||||
|
# ganha; se nenhum casar, cai no fallback por /seri/ (ver #serie_de) — foi
|
||||||
|
# chutar nome de campo que deixou meia planilha vazia antes.
|
||||||
|
CAMPOS_SERIE = %w[
|
||||||
|
numero_de_serie_da_base_aparelho numero_de_serie_da_base numero_serie_base
|
||||||
|
num_serie_base serie_base numero_de_serie serie
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
# Devolve um resumo { datas:, visitas:, series:, falhas: } para o rake logar.
|
||||||
|
def perform(de: nil, ate: nil, operacao: nil, dias: DIAS_PADRAO)
|
||||||
|
return resumo_vazio('SIMPLIROUTE_TOKEN não configurado') unless SimpliRoute.configurado?
|
||||||
|
|
||||||
|
datas = datas_alvo(de: de, ate: ate, operacao: operacao, dias: dias)
|
||||||
|
return resumo_vazio('nenhuma data com visitas no período') if datas.empty?
|
||||||
|
|
||||||
|
visitas = varrer(datas)
|
||||||
|
gravadas = gravar(visitas)
|
||||||
|
|
||||||
|
avisar_se_nada_casou(gravadas, visitas)
|
||||||
|
{ datas: datas.size, visitas: visitas.size, series: gravadas, falhas: @falhas }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Datas que valem uma chamada à API. Sempre saem do espelho (planned_date das
|
||||||
|
# visitas que já conhecemos) — varrer dia sem visita é chamada jogada fora.
|
||||||
|
def datas_alvo(de:, ate:, operacao:, dias:)
|
||||||
|
escopo = Entrega.where.not(planned_date: nil)
|
||||||
|
escopo = escopo.da_operacoes([operacao]) if operacao.present?
|
||||||
|
|
||||||
|
inicio, fim = if de.present? && ate.present?
|
||||||
|
[de.to_date, ate.to_date]
|
||||||
|
elsif operacao.present?
|
||||||
|
[nil, nil] # operação inteira, sem recorte de data
|
||||||
|
else
|
||||||
|
[Date.current - dias.to_i, Date.current]
|
||||||
|
end
|
||||||
|
escopo = escopo.no_periodo(inicio, fim) if inicio && fim
|
||||||
|
|
||||||
|
escopo.distinct.pluck(Arel.sql('planned_date::date')).compact.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
# Um dia que a API não responde não pode derrubar a rodada: registra e segue
|
||||||
|
# (mesma postura da varredura de NF do Admin::EdicaoLancamentosController).
|
||||||
|
def varrer(datas)
|
||||||
|
@falhas = []
|
||||||
|
mutex = Mutex.new
|
||||||
|
|
||||||
|
datas.each_slice(MAX_PARALELO).flat_map do |lote|
|
||||||
|
lote.map { |data| Thread.new { visitas_do_dia(data, mutex) } }.flat_map(&:value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def visitas_do_dia(data, mutex)
|
||||||
|
client.visitas_da_data(data)
|
||||||
|
rescue SimpliRoute::Error => e
|
||||||
|
mutex.synchronize { @falhas << { data: data.to_s, erro: e.message } }
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Grava só as visitas que TÊM série. upsert_all em vez de N inserts: uma
|
||||||
|
# operação de um mês passa de 1.000 visitas.
|
||||||
|
def gravar(visitas)
|
||||||
|
agora = Time.current
|
||||||
|
registros = visitas.filter_map do |visita|
|
||||||
|
serie = serie_de(visita)
|
||||||
|
next if serie.blank? || visita['tracking_id'].blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
tracking_id: visita['tracking_id'],
|
||||||
|
reference_id: visita['reference'].to_s.presence,
|
||||||
|
numero_serie: serie,
|
||||||
|
planned_date: visita['planned_date'],
|
||||||
|
sincronizado_em: agora
|
||||||
|
}
|
||||||
|
end
|
||||||
|
# A mesma visita pode vir em duas datas varridas; upsert_all rejeita chave
|
||||||
|
# repetida no MESMO lote, então deduplica antes (fica a última).
|
||||||
|
registros = registros.index_by { |r| r[:tracking_id] }.values
|
||||||
|
return 0 if registros.empty?
|
||||||
|
|
||||||
|
SerieAparelho.upsert_all(registros, unique_by: :tracking_id, record_timestamps: true)
|
||||||
|
registros.size
|
||||||
|
end
|
||||||
|
|
||||||
|
# A série fica em `extra_field_values`, o mesmo hash das fotos do motorista.
|
||||||
|
# Ignora valor que seja URL: ali dentro convivem os campos foto_*.
|
||||||
|
def serie_de(visita)
|
||||||
|
extras = visita['extra_field_values']
|
||||||
|
return nil unless extras.is_a?(Hash)
|
||||||
|
|
||||||
|
chave = CAMPOS_SERIE.find { |c| texto_util?(extras[c]) } ||
|
||||||
|
extras.keys.find { |k| k.to_s.match?(/seri/i) && texto_util?(extras[k]) }
|
||||||
|
chave && extras[chave].to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def texto_util?(valor)
|
||||||
|
v = valor.to_s.strip
|
||||||
|
v.present? && !v.match?(%r{\Ahttps?://}i)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Varreu visitas e não achou UMA série? Quase certo que o campo mudou de nome
|
||||||
|
# na API. Loga as chaves que vieram para não virar falha silenciosa de novo.
|
||||||
|
def avisar_se_nada_casou(gravadas, visitas)
|
||||||
|
return if gravadas.positive? || visitas.empty?
|
||||||
|
|
||||||
|
chaves = visitas.filter_map { |v| v['extra_field_values'] }
|
||||||
|
.select { |e| e.is_a?(Hash) }.flat_map(&:keys).uniq.sort
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[series_aparelho] #{visitas.size} visitas varridas e nenhuma série encontrada. " \
|
||||||
|
"Campos vistos em extra_field_values: #{chaves.inspect}. " \
|
||||||
|
'Se o campo da série estiver nessa lista, acrescente o nome em CAMPOS_SERIE.'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def client
|
||||||
|
@client ||= SimpliRoute::Client.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def resumo_vazio(motivo)
|
||||||
|
Rails.logger.info("[series_aparelho] nada a fazer — #{motivo}.")
|
||||||
|
{ datas: 0, visitas: 0, series: 0, falhas: [], motivo: motivo }
|
||||||
|
end
|
||||||
|
end
|
||||||
23
app/models/serie_aparelho.rb
Normal file
23
app/models/serie_aparelho.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# app/models/serie_aparelho.rb
|
||||||
|
#
|
||||||
|
# Número de série da base (aparelho) de UMA visita do SimpliRoute, buscado na
|
||||||
|
# API pelo SincronizarSeriesAparelhoJob.
|
||||||
|
#
|
||||||
|
# Esta é uma tabela NOSSA (gravável) — existe porque o espelho read-only
|
||||||
|
# db_reem_simplerout_2026 tem a coluna `numero_de_serie_da_base_aparelho` mas
|
||||||
|
# nunca a preenche. Ver a migration para o histórico.
|
||||||
|
class SerieAparelho < ApplicationRecord
|
||||||
|
self.table_name = 'series_aparelho'
|
||||||
|
|
||||||
|
validates :tracking_id, presence: true, uniqueness: true
|
||||||
|
validates :numero_serie, presence: true
|
||||||
|
|
||||||
|
# { tracking_id => numero_serie } das visitas informadas — uma consulta só,
|
||||||
|
# para a planilha não fazer N+1 ao preencher a coluna.
|
||||||
|
def self.por_tracking(tracking_ids)
|
||||||
|
ids = Array(tracking_ids).compact.uniq
|
||||||
|
return {} if ids.empty?
|
||||||
|
|
||||||
|
where(tracking_id: ids).pluck(:tracking_id, :numero_serie).to_h
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,8 +4,13 @@
|
|||||||
# operação sensível e irreversível. Restrita a admin.
|
# operação sensível e irreversível. Restrita a admin.
|
||||||
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
|
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
|
||||||
class EdicaoLancamentoPolicy < ApplicationPolicy
|
class EdicaoLancamentoPolicy < ApplicationPolicy
|
||||||
|
# ⚠️ Uma entrada por action do controller: o Pundit chama "<action>?" e, se o
|
||||||
|
# método não existir, levanta NoMethodError (500) — foi o que aconteceu ao
|
||||||
|
# adicionar `carregar` sem passar por aqui. Ação nova no controller ⇒ linha
|
||||||
|
# nova aqui.
|
||||||
def show? = admin?
|
def show? = admin?
|
||||||
def buscar? = admin?
|
def buscar? = admin?
|
||||||
|
def carregar? = admin?
|
||||||
def atualizar? = admin?
|
def atualizar? = admin?
|
||||||
def historico? = admin?
|
def historico? = admin?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -42,17 +42,35 @@ module Analytics
|
|||||||
'nota_fiscal' => 'NOTA FISCAL'
|
'nota_fiscal' => 'NOTA FISCAL'
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
# Colunas da gade cujo nome real difere da chave de COLUNAS_GADE (a chave é
|
||||||
|
# o alias usado no SELECT e na leitura da linha). As tabelas do cliente
|
||||||
|
# gravam a geo como `lat`/`long`, não `latitude`/`longitude` — sem isso as
|
||||||
|
# duas colunas saíam vazias. Ordem = preferência; a 1ª que existir é usada.
|
||||||
|
ALIAS_GADE = {
|
||||||
|
'latitude' => %w[latitude lat],
|
||||||
|
'longitude' => %w[longitude long]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
# Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio).
|
# Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio).
|
||||||
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
|
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
|
||||||
|
|
||||||
# Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
|
# Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
|
||||||
# de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
|
# de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
|
||||||
|
#
|
||||||
|
# O 1º candidato de cada linha é o nome REAL conferido na
|
||||||
|
# db_reem_simplerout_2026 (o espelho usa os nomes do export do SimpliRoute:
|
||||||
|
# `codrivers`, `responsible_person`, `advance`, `start_of_time_window_1`,
|
||||||
|
# `required_skills`...). Os demais candidatos são só rede de segurança para
|
||||||
|
# o dia em que o espelho for recriado com outra grafia.
|
||||||
|
#
|
||||||
|
# 'Load 4' fica sem candidato de propósito: essa coluna NÃO existe no
|
||||||
|
# espelho (só load, load_2 e load_3) — sai vazia, preservando o layout.
|
||||||
RASTREIO_COLUNAS = {
|
RASTREIO_COLUNAS = {
|
||||||
'Tracking ID' => %w[tracking_id],
|
'Tracking ID' => %w[tracking_id],
|
||||||
'Referência ID' => %w[reference_id],
|
'Referência ID' => %w[reference_id],
|
||||||
'Data prevista' => %w[planned_date],
|
'Data prevista' => %w[planned_date],
|
||||||
'Motorista' => %w[driver],
|
'Motorista' => %w[driver],
|
||||||
'Co-pilotos' => %w[copilots co_pilotos copilotos],
|
'Co-pilotos' => %w[codrivers copilots co_pilotos copilotos],
|
||||||
'Veículo' => %w[vehicle],
|
'Veículo' => %w[vehicle],
|
||||||
'Título' => %w[title titulo],
|
'Título' => %w[title titulo],
|
||||||
'Endereço' => %w[address],
|
'Endereço' => %w[address],
|
||||||
@@ -60,10 +78,10 @@ module Analytics
|
|||||||
'ETD' => %w[etd],
|
'ETD' => %w[etd],
|
||||||
'Checkin' => %w[checkin],
|
'Checkin' => %w[checkin],
|
||||||
'Checkout' => %w[checkout],
|
'Checkout' => %w[checkout],
|
||||||
'Responsável' => %w[receiver responsavel responsible],
|
'Responsável' => %w[responsible_person receiver responsavel responsible],
|
||||||
'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
|
'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
|
||||||
'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
|
'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
|
||||||
'Antecipação' => %w[early antecipacao anticipation],
|
'Antecipação' => %w[advance early antecipacao anticipation],
|
||||||
'Atraso' => %w[delay atraso],
|
'Atraso' => %w[delay atraso],
|
||||||
'Latitude' => %w[latitude],
|
'Latitude' => %w[latitude],
|
||||||
'Longitude' => %w[longitude],
|
'Longitude' => %w[longitude],
|
||||||
@@ -72,16 +90,16 @@ module Analytics
|
|||||||
'Load' => %w[load load_1 carga],
|
'Load' => %w[load load_1 carga],
|
||||||
'Load 2' => %w[load_2],
|
'Load 2' => %w[load_2],
|
||||||
'Load 3' => %w[load_3],
|
'Load 3' => %w[load_3],
|
||||||
'Load 4' => %w[load_4],
|
'Load 4' => %w[],
|
||||||
'Estado' => %w[status],
|
'Estado' => %w[status],
|
||||||
'Comentários' => %w[checkout_comment comments comentarios comment],
|
'Comentários' => %w[comments checkout_comment comentarios comment],
|
||||||
'Observações' => %w[observation observacoes],
|
'Observações' => %w[observation observacoes],
|
||||||
'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1],
|
'Janela de horário Inicial 1' => %w[start_of_time_window_1 window_start janela_inicial_1],
|
||||||
'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1],
|
'Janela de horário Final 1' => %w[end_of_time_window_1 window_end janela_final_1],
|
||||||
'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2],
|
'Janela de horário Inicial 2' => %w[start_of_time_window_2 window_start_2 janela_inicial_2],
|
||||||
'Janela de horário Final 2' => %w[window_end_2 janela_final_2],
|
'Janela de horário Final 2' => %w[end_of_time_window_2 window_end_2 janela_final_2],
|
||||||
'Habilidades necessárias' => %w[skills_required habilidades_necessarias],
|
'Habilidades necessárias' => %w[required_skills skills_required habilidades_necessarias],
|
||||||
'Habilidades adicionais' => %w[skills_optional habilidades_adicionais],
|
'Habilidades adicionais' => %w[optional_skills skills_optional habilidades_adicionais],
|
||||||
'Notas' => %w[notes notas],
|
'Notas' => %w[notes notas],
|
||||||
'Nome de contato' => %w[contact_name],
|
'Nome de contato' => %w[contact_name],
|
||||||
'Telefone de contato' => %w[contact_phone telefone_de_contato],
|
'Telefone de contato' => %w[contact_phone telefone_de_contato],
|
||||||
@@ -93,7 +111,10 @@ module Analytics
|
|||||||
'Termo de Recebimento' => %w[termo_de_recebimento],
|
'Termo de Recebimento' => %w[termo_de_recebimento],
|
||||||
'Foto da Fachada' => %w[foto_da_fachada],
|
'Foto da Fachada' => %w[foto_da_fachada],
|
||||||
'Relatório de visita' => %w[relatorio_de_visita visit_report],
|
'Relatório de visita' => %w[relatorio_de_visita visit_report],
|
||||||
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base]
|
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base],
|
||||||
|
# Campo do espelho que o modelo antigo do cliente ainda não tinha. Entra
|
||||||
|
# DEPOIS das colunas do modelo para não deslocar o layout A..AT dele.
|
||||||
|
'Protocolo de Entrega' => %w[protocolo_de_entrega]
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# Quadro do RESUMO — linhas fixas do modelo.
|
# Quadro do RESUMO — linhas fixas do modelo.
|
||||||
@@ -145,20 +166,24 @@ module Analytics
|
|||||||
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
|
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
|
||||||
# como no dump original do SimpliRoute).
|
# como no dump original do SimpliRoute).
|
||||||
def linhas_rastreio
|
def linhas_rastreio
|
||||||
gade = conn.quote_table_name(@tabela)
|
@linhas_rastreio ||= begin
|
||||||
sql = <<~SQL
|
gade = conn.quote_table_name(@tabela)
|
||||||
SELECT r.*
|
sql = <<~SQL
|
||||||
FROM #{conn.quote_table_name(Entrega.table_name)} r
|
SELECT r.*
|
||||||
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
|
FROM #{conn.quote_table_name(Entrega.table_name)} r
|
||||||
ORDER BY r.reference_id, r.checkout NULLS LAST
|
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
|
||||||
SQL
|
ORDER BY r.reference_id, r.checkout NULLS LAST
|
||||||
conn.select_all(sql).to_a
|
SQL
|
||||||
|
completar_serie(conn.select_all(sql).to_a)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
|
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
|
||||||
def colunas_rastreio
|
def colunas_rastreio
|
||||||
existentes = conn.columns(Entrega.table_name).map(&:name)
|
@colunas_rastreio ||= begin
|
||||||
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
|
existentes = conn.columns(Entrega.table_name).map(&:name)
|
||||||
|
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Aba RESUMO ───────────────────────────────────────────────
|
# ── Aba RESUMO ───────────────────────────────────────────────
|
||||||
@@ -203,11 +228,31 @@ module Analytics
|
|||||||
ActiveRecord::Base.connection
|
ActiveRecord::Base.connection
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# O espelho tem a coluna do número de série mas o sync NUNCA a preenche
|
||||||
|
# (0 de ~6.000 linhas em fev/mar/mai/jul/2026) e a tabela é read-only —
|
||||||
|
# quem busca o dado na API é o SincronizarSeriesAparelhoJob, que grava em
|
||||||
|
# series_aparelho. Aqui só completamos as linhas em que o espelho veio
|
||||||
|
# vazio: se um dia o sync passar a preencher, o valor dele continua valendo.
|
||||||
|
def completar_serie(linhas)
|
||||||
|
coluna = colunas_rastreio['Número de Série da Base (Aparelho)']
|
||||||
|
return linhas if coluna.nil?
|
||||||
|
|
||||||
|
pendentes = linhas.reject { |r| r[coluna].present? }
|
||||||
|
return linhas if pendentes.empty?
|
||||||
|
|
||||||
|
series = SerieAparelho.por_tracking(pendentes.map { |r| r['tracking_id'] })
|
||||||
|
pendentes.each { |r| r[coluna] = series[r['tracking_id']] if series[r['tracking_id']] }
|
||||||
|
linhas
|
||||||
|
end
|
||||||
|
|
||||||
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
|
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
|
||||||
|
# O nome real pode diferir da chave (ver ALIAS_GADE) — o alias no SELECT é
|
||||||
|
# sempre a chave, então quem lê a linha não precisa saber da diferença.
|
||||||
def selects_gade
|
def selects_gade
|
||||||
existentes = conn.columns(@tabela).map(&:name)
|
existentes = conn.columns(@tabela).map(&:name)
|
||||||
COLUNAS_GADE.keys.map do |c|
|
COLUNAS_GADE.keys.map do |c|
|
||||||
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
|
real = (ALIAS_GADE[c] || [c]).find { |cand| existentes.include?(cand) }
|
||||||
|
real ? "g.#{conn.quote_column_name(real)} AS #{c}" : "CAST(NULL AS text) AS #{c}"
|
||||||
end.join(', ')
|
end.join(', ')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ module Analytics
|
|||||||
# Cabeçalho da ENTREGAS
|
# Cabeçalho da ENTREGAS
|
||||||
@cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true)
|
@cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true)
|
||||||
@cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true)
|
@cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true)
|
||||||
|
|
||||||
|
# Hiperlink (fotos/comprovantes do SimpliRoute) — azul sublinhado padrão.
|
||||||
|
@link = st.add_style(fg_color: '0563C1', u: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── RESUMO ───────────────────────────────────────────────────
|
# ── RESUMO ───────────────────────────────────────────────────
|
||||||
@@ -183,19 +186,38 @@ module Analytics
|
|||||||
# Dump cru do rastreio (todas as visitas das NFs da operação), com o
|
# Dump cru do rastreio (todas as visitas das NFs da operação), com o
|
||||||
# cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem
|
# cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem
|
||||||
# coluna correspondente sai vazio.
|
# coluna correspondente sai vazio.
|
||||||
|
#
|
||||||
|
# As colunas de foto (Nota Fiscal, Termo de Recebimento, Foto da Fachada,
|
||||||
|
# Relatório de visita) guardam URLs do S3 do SimpliRoute: viram HIPERLINK
|
||||||
|
# clicável (azul sublinhado), para o cliente abrir o comprovante direto do
|
||||||
|
# Excel em vez de copiar e colar a URL.
|
||||||
def aba_simpliroute(wb)
|
def aba_simpliroute(wb)
|
||||||
colunas = @planilha.colunas_rastreio
|
colunas = @planilha.colunas_rastreio
|
||||||
linhas = @planilha.linhas_rastreio
|
linhas = @planilha.linhas_rastreio
|
||||||
wb.add_worksheet(name: 'SimpliRoute') do |sheet|
|
wb.add_worksheet(name: 'SimpliRoute') do |sheet|
|
||||||
sheet.add_row(colunas.keys)
|
sheet.add_row(colunas.keys)
|
||||||
linhas.each do |r|
|
linhas.each do |r|
|
||||||
sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' },
|
valores = colunas.values.map { |col| col ? texto(r[col]) : '' }
|
||||||
types: Array.new(colunas.size, :string))
|
sheet.add_row(valores, types: Array.new(colunas.size, :string))
|
||||||
|
linkar_urls(sheet, valores)
|
||||||
end
|
end
|
||||||
sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}"
|
sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Transforma em hiperlink toda célula da linha recém-adicionada cujo valor
|
||||||
|
# seja uma URL http(s) — mesma checagem do foto_url do OperacaoMetricas,
|
||||||
|
# para não jogar lixo dentro do <hyperlink> do arquivo.
|
||||||
|
def linkar_urls(sheet, valores)
|
||||||
|
linha = sheet.rows.size
|
||||||
|
valores.each_with_index do |valor, i|
|
||||||
|
next unless valor.to_s.match?(%r{\Ahttps?://\S+\z}i)
|
||||||
|
|
||||||
|
sheet.add_hyperlink(location: valor, ref: "#{Axlsx.col_ref(i)}#{linha}")
|
||||||
|
sheet.rows.last.cells[i].style = @link
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def texto(valor)
|
def texto(valor)
|
||||||
valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s
|
valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,14 +41,38 @@ module SimpliRoute
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Todas as visitas de uma data (Array de hashes).
|
# Todas as visitas de uma data (Array de hashes).
|
||||||
def visitas_da_data(data)
|
#
|
||||||
Array(get("/v1/routes/visits/?planned_date=#{data.to_date.iso8601}"))
|
# `busca` vira `&search=` — parâmetro NÃO documentado, mas verificado contra
|
||||||
|
# a API real em 21/07/2026: filtra de verdade e derruba a resposta de um dia
|
||||||
|
# de ~3,8 MB / ~9 s (1879 visitas) para ~1 KB / ~0,6 s. Sem ele, varrer um
|
||||||
|
# mês é inviável.
|
||||||
|
#
|
||||||
|
# ⚠️ NÃO confiar só nele: `reference`, `reference_id`, `q` e `title` são
|
||||||
|
# ignorados em SILÊNCIO pela API (devolvem 200 com o dia inteiro). Se o
|
||||||
|
# `search` mudar de nome, cai no mesmo comportamento — por isso quem chama
|
||||||
|
# continua filtrando por `reference`, e a perda é só de desempenho.
|
||||||
|
#
|
||||||
|
# ⚠️ SEM `planned_date` a API responde um conjunto padrão (~2469 visitas,
|
||||||
|
# medido) que NÃO cobre o histórico: a NF 82891 voltava só com a visita de
|
||||||
|
# 21/07, escondendo a de 17/07. Todo filtro de intervalo testado
|
||||||
|
# (planned_date_from/to, since/until, __gte/__lte, date_from/to) é ignorado.
|
||||||
|
# Por isso a data continua obrigatória e a varredura é dia a dia.
|
||||||
|
def visitas_da_data(data, busca: nil)
|
||||||
|
caminho = "/v1/routes/visits/?planned_date=#{data.to_date.iso8601}"
|
||||||
|
caminho += "&search=#{URI.encode_www_form_component(busca.to_s)}" if busca.present?
|
||||||
|
|
||||||
|
Array(get(caminho))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
|
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
|
||||||
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
|
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
|
||||||
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
|
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
|
||||||
# Levanta NotFound (nada) ou Error (ambiguidade sem tracking_id).
|
# Levanta NotFound (nada) ou Error (ambiguidade sem tracking_id).
|
||||||
|
#
|
||||||
|
# ⚠️ Ambiguidade por NF é REAL (plano duplicado gera duas visitas com a mesma
|
||||||
|
# NF). Quem precisa lidar com isso — a tela de edição — usa
|
||||||
|
# `visitas_da_data` + `casa_entrega?` e deixa o ADM escolher; este método
|
||||||
|
# continua servindo os fluxos que exigem resposta única.
|
||||||
def resolver_id(entrega)
|
def resolver_id(entrega)
|
||||||
data = entrega.planned_date&.to_date
|
data = entrega.planned_date&.to_date
|
||||||
raise NotFound, 'Entrega sem planned_date — impossível localizar na API.' if data.blank?
|
raise NotFound, 'Entrega sem planned_date — impossível localizar na API.' if data.blank?
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
<%# Pilares de preço %>
|
<%# Pilares de preço %>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<%# auto-fit: `lg:grid-cols-4` mede a JANELA, mas a sidebar come 256px do
|
||||||
|
conteúdo — os cards de preço ficavam com ~128px úteis e o valor em
|
||||||
|
moeda espremia. Aqui o número de colunas segue a largura REAL. %>
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-4">
|
||||||
<% @configuracoes.each do |cfg| %>
|
<% @configuracoes.each do |cfg| %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
|
/history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
|
||||||
<div class="space-y-6" id="edicao-app"
|
<div class="space-y-6" id="edicao-app"
|
||||||
data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"
|
data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"
|
||||||
|
data-carregar-url="<%= carregar_admin_edicao_lancamento_path %>"
|
||||||
data-atualizar-url="<%= atualizar_admin_edicao_lancamento_path %>"
|
data-atualizar-url="<%= atualizar_admin_edicao_lancamento_path %>"
|
||||||
data-historico-url="<%= historico_admin_edicao_lancamento_path %>"
|
data-historico-url="<%= historico_admin_edicao_lancamento_path %>"
|
||||||
data-csrf="<%= form_authenticity_token %>"
|
data-csrf="<%= form_authenticity_token %>"
|
||||||
@@ -39,8 +40,50 @@
|
|||||||
Buscar
|
Buscar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<%# Período — a API do SimpliRoute só filtra visita por UM dia, então achar
|
||||||
|
uma NF exige saber em quais dias procurar. Sem período, procuramos nas
|
||||||
|
datas que o painel já conhece; quando o plano foi duplicado para outra
|
||||||
|
data, o painel não sabe dela e é preciso ampliar aqui. %>
|
||||||
|
<div class="flex flex-wrap items-end gap-2 mt-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1" for="campo-de">De</label>
|
||||||
|
<input id="campo-de" type="date"
|
||||||
|
class="bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2 text-white text-sm
|
||||||
|
focus:outline-none focus:border-orange-500 min-h-[42px]">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1" for="campo-ate">Até</label>
|
||||||
|
<input id="campo-ate" type="date"
|
||||||
|
class="bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2 text-white text-sm
|
||||||
|
focus:outline-none focus:border-orange-500 min-h-[42px]">
|
||||||
|
</div>
|
||||||
|
<button id="btn-30dias" type="button"
|
||||||
|
class="px-4 py-2 bg-[#1a1a1a] hover:bg-[#252525] border border-white/10 text-gray-200
|
||||||
|
text-sm rounded-xl transition-colors min-h-[42px] whitespace-nowrap">
|
||||||
|
Últimos 30 dias
|
||||||
|
</button>
|
||||||
|
<button id="btn-limpar-periodo" type="button"
|
||||||
|
class="px-3 py-2 text-gray-500 hover:text-gray-300 text-sm min-h-[42px]">Limpar</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
Sem período, procuramos só nas datas que o painel conhece. Se o plano foi duplicado para outra
|
||||||
|
data, amplie o período — a API do SimpliRoute não busca NF por intervalo, então varremos dia a
|
||||||
|
dia (máx. 62 dias).
|
||||||
|
</p>
|
||||||
<p id="busca-erro" class="hidden mt-2 text-sm text-red-400"></p>
|
<p id="busca-erro" class="hidden mt-2 text-sm text-red-400"></p>
|
||||||
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
|
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
|
||||||
|
<p id="busca-periodo" class="hidden mt-2 text-xs text-gray-500"></p>
|
||||||
|
<div id="busca-falhas" class="hidden mt-2 p-3 rounded-xl bg-red-900/20 border border-red-500/40 text-xs text-red-200 space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Lançamentos da NF — só aparece quando há mais de um. Plano duplicado no
|
||||||
|
SimpliRoute cria uma visita nova com a MESMA NF, e a antiga (a que tem o
|
||||||
|
motorista) continua valendo: aqui o ADM vê as duas e escolhe qual editar. %>
|
||||||
|
<div id="ocorrencias" class="hidden bg-[#111] border border-white/5 rounded-2xl p-5 space-y-3">
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
<span id="oc-contador" class="font-semibold text-white"></span> <span id="oc-dica"></span>
|
||||||
|
</p>
|
||||||
|
<div id="oc-lista" class="space-y-2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Card do lançamento (preenchido via JS após a busca) %>
|
<%# Card do lançamento (preenchido via JS após a busca) %>
|
||||||
@@ -142,13 +185,16 @@
|
|||||||
const app = document.getElementById('edicao-app');
|
const app = document.getElementById('edicao-app');
|
||||||
const URLS = {
|
const URLS = {
|
||||||
buscar: app.dataset.buscarUrl,
|
buscar: app.dataset.buscarUrl,
|
||||||
|
carregar: app.dataset.carregarUrl,
|
||||||
atualizar: app.dataset.atualizarUrl,
|
atualizar: app.dataset.atualizarUrl,
|
||||||
historico: app.dataset.historicoUrl
|
historico: app.dataset.historicoUrl
|
||||||
};
|
};
|
||||||
const CSRF = app.dataset.csrf;
|
const CSRF = app.dataset.csrf;
|
||||||
const MOTIVOS = JSON.parse(app.dataset.motivos || '[]');
|
const MOTIVOS = JSON.parse(app.dataset.motivos || '[]');
|
||||||
|
|
||||||
let visita = null; // estado atual do lançamento carregado
|
let visita = null; // estado atual do lançamento carregado
|
||||||
|
let ocorrencias = []; // todos os lançamentos da NF buscada
|
||||||
|
let selecionado = null; // tracking_id do lançamento aberto no card
|
||||||
|
|
||||||
// ── Config dos campos editáveis ─────────────────────────────
|
// ── Config dos campos editáveis ─────────────────────────────
|
||||||
// Ícone do sprite (public/icons.svg). fill=currentColor -> herda a cor do badge.
|
// Ícone do sprite (public/icons.svg). fill=currentColor -> herda a cor do badge.
|
||||||
@@ -185,31 +231,191 @@
|
|||||||
const loadEl = document.getElementById('busca-loading');
|
const loadEl = document.getElementById('busca-loading');
|
||||||
const cardEl = document.getElementById('card');
|
const cardEl = document.getElementById('card');
|
||||||
|
|
||||||
|
const campoDe = document.getElementById('campo-de');
|
||||||
|
const campoAte = document.getElementById('campo-ate');
|
||||||
|
const periodoEl = document.getElementById('busca-periodo');
|
||||||
|
const falhasEl = document.getElementById('busca-falhas');
|
||||||
|
|
||||||
async function buscar() {
|
async function buscar() {
|
||||||
const nf = campoNf.value.trim();
|
const nf = campoNf.value.trim();
|
||||||
erroEl.classList.add('hidden');
|
erroEl.classList.add('hidden');
|
||||||
|
periodoEl.classList.add('hidden');
|
||||||
|
falhasEl.classList.add('hidden');
|
||||||
if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
|
if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
|
||||||
|
|
||||||
loadEl.classList.remove('hidden');
|
loadEl.classList.remove('hidden');
|
||||||
cardEl.classList.add('hidden');
|
cardEl.classList.add('hidden');
|
||||||
|
ocorrencias = [];
|
||||||
|
selecionado = null;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
|
let url = URLS.buscar + '?nf=' + encodeURIComponent(nf);
|
||||||
|
if (campoDe.value) url += '&de=' + encodeURIComponent(campoDe.value);
|
||||||
|
if (campoAte.value) url += '&ate=' + encodeURIComponent(campoAte.value);
|
||||||
|
const resp = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
|
if (!data.ok) { renderOcorrencias(); mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
|
||||||
visita = data.visita;
|
ocorrencias = data.ocorrencias || [];
|
||||||
renderCard();
|
renderPeriodo(data.periodo, data.falhas);
|
||||||
cardEl.classList.remove('hidden');
|
// Só veio o card pronto quando a NF tem um lançamento único; com mais de
|
||||||
|
// um, o ADM escolhe na lista antes de abrir qualquer coisa.
|
||||||
|
if (data.visita) {
|
||||||
|
abrirCard(data.visita);
|
||||||
|
} else {
|
||||||
|
visita = null;
|
||||||
|
renderOcorrencias();
|
||||||
|
// Tela em branco é pior que erro: se não há card E a lista ficou
|
||||||
|
// escondida, o ADM precisa saber por quê em vez de achar que a NF
|
||||||
|
// não existe.
|
||||||
|
if (ocEl.classList.contains('hidden')) {
|
||||||
|
mostrarErroBusca(ocorrencias.length
|
||||||
|
? 'O lançamento foi localizado mas não pôde ser aberto — veja os avisos acima.'
|
||||||
|
: 'Nenhum lançamento localizado para esta NF no período consultado.');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mostrarErroBusca('Erro de conexão ao buscar a NF.');
|
// Mostra o erro real: "Erro de conexão" genérico escondeu uma exceção de
|
||||||
|
// renderização e deixou a tela muda.
|
||||||
|
console.error('[edicao-lancamento] falha na busca', e);
|
||||||
|
mostrarErroBusca('Falha ao processar a busca: ' + ((e && e.message) || e));
|
||||||
} finally {
|
} finally {
|
||||||
loadEl.classList.add('hidden');
|
loadEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
|
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
|
||||||
|
|
||||||
|
// Dizer o que foi consultado e o que falhou. Uma busca que devolve menos do
|
||||||
|
// que existe precisa admitir isso — senão parece que a NF não existe.
|
||||||
|
function renderPeriodo(periodo, falhas) {
|
||||||
|
if (periodo && periodo.dias) {
|
||||||
|
periodoEl.textContent = 'Consultado: ' + fmtData(periodo.de) +
|
||||||
|
(periodo.dias > 1 ? ' a ' + fmtData(periodo.ate) + ' (' + periodo.dias + ' dias)' : '') + '.';
|
||||||
|
periodoEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (falhas && falhas.length) {
|
||||||
|
falhasEl.innerHTML = '<p class="font-semibold">A API do SimpliRoute falhou em ' + falhas.length +
|
||||||
|
' dia(s) — pode haver lançamento faltando na lista:</p>' +
|
||||||
|
falhas.map(f => '<p>' + esc(f.data) + ' — ' + esc(f.erro) + '</p>').join('');
|
||||||
|
falhasEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('btn-buscar').addEventListener('click', buscar);
|
document.getElementById('btn-buscar').addEventListener('click', buscar);
|
||||||
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
||||||
|
|
||||||
|
document.getElementById('btn-30dias').addEventListener('click', () => {
|
||||||
|
const hoje = new Date();
|
||||||
|
const trinta = new Date(hoje.getTime() - 30 * 86400000);
|
||||||
|
const iso = d => d.toISOString().slice(0, 10);
|
||||||
|
campoDe.value = iso(trinta);
|
||||||
|
campoAte.value = iso(hoje);
|
||||||
|
if (campoNf.value.trim()) buscar();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-limpar-periodo').addEventListener('click', () => {
|
||||||
|
campoDe.value = ''; campoAte.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lista de lançamentos da NF ──────────────────────────────
|
||||||
|
const ocEl = document.getElementById('ocorrencias');
|
||||||
|
const ocLista = document.getElementById('oc-lista');
|
||||||
|
const ocCont = document.getElementById('oc-contador');
|
||||||
|
const ocDica = document.getElementById('oc-dica');
|
||||||
|
|
||||||
|
// ⚠️ NÃO usar new Date('2026-07-15') para data pura: o JS lê como meia-noite
|
||||||
|
// UTC e o toLocale em UTC-3 devolve o DIA ANTERIOR. Data planejada não é um
|
||||||
|
// instante — formata direto do texto ISO.
|
||||||
|
const fmtData = v => {
|
||||||
|
if (!v) return '—';
|
||||||
|
const s = String(v);
|
||||||
|
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (iso) return iso[3] + '/' + iso[2] + '/' + iso[1];
|
||||||
|
const d = new Date(s);
|
||||||
|
return isNaN(d) ? s.slice(0, 10) : d.toLocaleDateString('pt-BR');
|
||||||
|
};
|
||||||
|
const fmtHora = v => {
|
||||||
|
if (!v) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d) ? null : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Com um lançamento editável só, a lista some — a tela fica igual ao que era
|
||||||
|
// antes. Fica visível se ele não for editável, para explicar o porquê.
|
||||||
|
function renderOcorrencias() {
|
||||||
|
const n = ocorrencias.length;
|
||||||
|
if (n === 0 || (n === 1 && ocorrencias[0].editavel)) {
|
||||||
|
ocEl.classList.add('hidden'); ocLista.innerHTML = ''; return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// O bloco nasce com `hidden` no ERB — sem tirar a classe aqui, a lista é
|
||||||
|
// montada e fica invisível (a tela parecia não ter achado nada).
|
||||||
|
ocEl.classList.remove('hidden');
|
||||||
|
ocCont.textContent = n + (n === 1 ? ' lançamento' : ' lançamentos');
|
||||||
|
ocDica.textContent = n > 1 ? 'para esta NF — escolha qual quer editar.' : 'para esta NF.';
|
||||||
|
ocLista.innerHTML = ocorrencias.map((o, i) => {
|
||||||
|
const cfg = STATUS[o.status] || { label: o.status || '—', cls: 'bg-gray-800 text-gray-400', icone: 'question-circle' };
|
||||||
|
const ativo = selecionado != null && String(selecionado) === String(o.tracking_id);
|
||||||
|
return '' +
|
||||||
|
'<div class="flex flex-wrap items-center justify-between gap-3 p-3 rounded-xl border ' +
|
||||||
|
(ativo ? 'border-orange-500/60 bg-orange-500/10' : 'border-white/10 bg-[#1a1a1a]') + '">' +
|
||||||
|
'<div class="min-w-0">' +
|
||||||
|
'<div class="flex items-center gap-2 flex-wrap">' +
|
||||||
|
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold ' + cfg.cls + '">' +
|
||||||
|
ico(cfg.icone) + ' ' + esc(cfg.label) + '</span>' +
|
||||||
|
'<span class="text-white text-sm font-semibold">' + esc(fmtData(o.data)) + '</span>' +
|
||||||
|
'<span class="text-xs text-gray-500">Visita ' + esc(o.id ?? '—') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="text-xs text-gray-400 mt-1">' +
|
||||||
|
// "sem motorista" (o painel sabe e está vazio) é diferente de
|
||||||
|
// "não identificado" (a visita nem está no painel ainda).
|
||||||
|
(o.motorista ? esc(o.motorista)
|
||||||
|
: '<span class="text-gray-600">' + (o.no_painel ? 'sem motorista' : 'motorista não identificado') + '</span>') +
|
||||||
|
' · ' +
|
||||||
|
(o.veiculo ? esc(o.veiculo) : '<span class="text-gray-600">' + (o.no_painel ? 'sem veículo' : 'veículo não identificado') + '</span>') +
|
||||||
|
(fmtHora(o.checkout_time) ? ' · <span class="text-gray-300">checkout ' + esc(fmtHora(o.checkout_time)) + '</span>' : '') +
|
||||||
|
'</p>' +
|
||||||
|
(o.aviso ? '<p class="text-xs text-amber-400 mt-1">' + esc(o.aviso) + '</p>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
(o.editavel
|
||||||
|
? '<button type="button" data-oc="' + i + '" class="px-4 py-2 text-sm font-semibold rounded-xl transition-colors ' +
|
||||||
|
(ativo ? 'bg-white/5 text-gray-400 border border-white/10' : 'bg-[#f97316] hover:bg-orange-500 text-white') + '">' +
|
||||||
|
(ativo ? 'Editando' : 'Editar') + '</button>'
|
||||||
|
: '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
ocLista.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('[data-oc]');
|
||||||
|
if (btn) abrirOcorrencia(ocorrencias[Number(btn.dataset.oc)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function abrirOcorrencia(o) {
|
||||||
|
if (!o || !o.id) return;
|
||||||
|
erroEl.classList.add('hidden');
|
||||||
|
loadEl.classList.remove('hidden');
|
||||||
|
cardEl.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(URLS.carregar + '?visit_id=' + encodeURIComponent(o.id) +
|
||||||
|
(o.tracking_id ? '&tracking_id=' + encodeURIComponent(o.tracking_id) : ''),
|
||||||
|
{ headers: { 'Accept': 'application/json' } });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível abrir este lançamento.'); return; }
|
||||||
|
abrirCard(data.visita);
|
||||||
|
cardEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
} catch (e) {
|
||||||
|
mostrarErroBusca('Erro de conexão ao abrir o lançamento.');
|
||||||
|
} finally {
|
||||||
|
loadEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirCard(v) {
|
||||||
|
visita = v;
|
||||||
|
selecionado = v.tracking_id;
|
||||||
|
renderCard();
|
||||||
|
renderOcorrencias(); // destaca na lista qual está aberto
|
||||||
|
cardEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Card ────────────────────────────────────────────────────
|
// ── Card ────────────────────────────────────────────────────
|
||||||
function renderCard() {
|
function renderCard() {
|
||||||
const v = visita;
|
const v = visita;
|
||||||
@@ -440,7 +646,12 @@
|
|||||||
if (data.campos && data.campos.length) Object.assign(visita, data.visita);
|
if (data.campos && data.campos.length) Object.assign(visita, data.visita);
|
||||||
pintarExibicao(div, def, data.campos && data.campos.length > 0);
|
pintarExibicao(div, def, data.campos && data.campos.length > 0);
|
||||||
div.classList.add('cursor-pointer');
|
div.classList.add('cursor-pointer');
|
||||||
if (def.campo === 'status') renderBadge();
|
if (def.campo === 'status') {
|
||||||
|
renderBadge();
|
||||||
|
// Mantém a lista coerente com o que acabou de ser gravado.
|
||||||
|
const o = ocorrencias.find(x => String(x.tracking_id) === String(visita.tracking_id));
|
||||||
|
if (o) { o.status = visita.status; renderOcorrencias(); }
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
msgErro.textContent = 'Erro de conexão ao salvar.';
|
msgErro.textContent = 'Erro de conexão ao salvar.';
|
||||||
msgErro.classList.remove('hidden');
|
msgErro.classList.remove('hidden');
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
<%# Pilares de preço %>
|
<%# Pilares de preço %>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<%# auto-fit: `lg:grid-cols-4` mede a JANELA, mas a sidebar come 256px do
|
||||||
|
conteúdo — os cards de preço ficavam com ~128px úteis e o valor em
|
||||||
|
moeda espremia. Aqui o número de colunas segue a largura REAL. %>
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] gap-4">
|
||||||
<% @configuracoes.each do |cfg| %>
|
<% @configuracoes.each do |cfg| %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %></p>
|
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Resumo por tipo %>
|
<%# Resumo por tipo — auto-fit em vez de `md:grid-cols-7`.
|
||||||
<div class="grid grid-cols-2 md:grid-cols-7 gap-3 mb-6">
|
Sete colunas fixas a partir de 768px dão ~55px cada quando a sidebar está
|
||||||
|
aberta (768 − 256 de menu − padding), destruindo rótulos como
|
||||||
|
"Extraordinária" e "Termo Especial". O mínimo de 9rem cabe o maior rótulo. %>
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3 mb-6">
|
||||||
<% [
|
<% [
|
||||||
['entrega_normal', 'Normal', 'bg-orange-500 text-black'],
|
['entrega_normal', 'Normal', 'bg-orange-500 text-black'],
|
||||||
['retirada', 'Retirada', 'bg-orange-800 text-white'],
|
['retirada', 'Retirada', 'bg-orange-800 text-white'],
|
||||||
|
|||||||
@@ -12,38 +12,45 @@
|
|||||||
<%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
|
<%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
|
||||||
<div class="lg:shrink-0">
|
<div class="lg:shrink-0">
|
||||||
|
|
||||||
<%# Header %>
|
<%# Cabeçalho compacto: voltar, identificação e progresso numa faixa só.
|
||||||
<div class="mb-3">
|
Espaço vertical aqui é CARO — tudo isto fica fixo e é subtraído da lista,
|
||||||
<%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao),
|
que é o que o ADM realmente precisa ver. Antes eram 178px em dois cards. %>
|
||||||
class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-2">
|
||||||
<div class="flex items-center justify-between mt-2">
|
<%= link_to wizard_consolidacao_path(@consolidacao), title: 'Voltar (Passo 1)',
|
||||||
<div>
|
class: 'shrink-0 inline-flex items-center justify-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-3 h-11 rounded-lg text-sm' do %>
|
||||||
<h1 class="text-2xl font-bold text-white flex items-center gap-2"><%= icone :caminhao, espaco: false %> <%= @motorista %></h1>
|
←<span class="hidden sm:inline">Passo 1</span>
|
||||||
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %></p>
|
<% end %>
|
||||||
</div>
|
|
||||||
<%= link_to safe_join(['Revisar (Passo 3)', icone(:avancar, cor: nil)], ' '),
|
<%# basis-40: o nome não pode ser espremido até virar "P…" — se não couber
|
||||||
revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
com o resto, a linha quebra em vez de esmagar a identificação. %>
|
||||||
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center' %>
|
<div class="min-w-0 flex-1 basis-40">
|
||||||
|
<h1 class="text-xl lg:text-2xl font-bold text-white flex items-center gap-2 truncate"><%= icone :caminhao, espaco: false %> <%= @motorista %></h1>
|
||||||
|
<p class="text-gray-400 text-sm truncate"><%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%# Progresso ao lado do título (em vez de um card de 70px só p/ ele). No
|
||||||
|
celular vai para uma linha própria, no fim, para não brigar pelo espaço.
|
||||||
|
Números em branco/negrito: esta é a informação que o ADM confere o tempo
|
||||||
|
todo enquanto classifica — compactar não pode custar a leitura dela. %>
|
||||||
|
<p class="order-last w-full text-left sm:order-none sm:w-auto sm:shrink-0 sm:text-right text-sm text-gray-400">
|
||||||
|
<% if @veiculos_sel.any? %><span class="text-gray-300"><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %></span> — <% end %>
|
||||||
|
<span class="text-white font-semibold"><span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>/<span data-validacao-target="contadorTotal"><%= @total_entregas %></span></span>
|
||||||
|
classificadas
|
||||||
|
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400">·
|
||||||
|
+<span data-validacao-target="contadorManuais"><%= @manuais %></span> manuais</span>
|
||||||
|
<span class="text-orange-500 font-bold ml-1 text-base" data-validacao-target="percentual"></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= link_to revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
||||||
|
class: 'shrink-0 bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 h-11 rounded-lg flex items-center gap-1 text-sm' do %>
|
||||||
|
Revisar<span class="hidden sm:inline"> (Passo 3)</span><%= icone :avancar, cor: nil %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Barra de progresso (reflete o veículo selecionado, se houver) %>
|
<%# Barra com 2 segmentos: laranja = elegíveis classificadas · azul = manuais %>
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
<div class="w-full bg-[#0a0a0a] rounded-full h-2 flex overflow-hidden mb-2">
|
||||||
<div class="flex justify-between text-sm mb-2">
|
<div data-validacao-target="barra" class="h-2 bg-orange-500 transition-all duration-300" style="width: 0%"></div>
|
||||||
<span class="text-white font-semibold">
|
<div data-validacao-target="barraManuais" class="h-2 bg-blue-500 transition-all duration-300" style="width: 0%"></div>
|
||||||
<% if @veiculos_sel.any? %><%= icone :caminhao %> <%= @veiculos_sel.join(', ') %> — <% end %>
|
|
||||||
<span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>
|
|
||||||
de <span data-validacao-target="contadorTotal"><%= @total_entregas %></span> entregas classificadas
|
|
||||||
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400 font-normal">·
|
|
||||||
+<span data-validacao-target="contadorManuais"><%= @manuais %></span> manuais</span>
|
|
||||||
</span>
|
|
||||||
<span class="text-orange-500 font-bold" data-validacao-target="percentual"></span>
|
|
||||||
</div>
|
|
||||||
<%# Barra com 2 segmentos: laranja/verde = elegíveis classificadas · azul = lançamentos manuais %>
|
|
||||||
<div class="w-full bg-[#0a0a0a] rounded-full h-3 flex overflow-hidden">
|
|
||||||
<div data-validacao-target="barra" class="h-3 bg-orange-500 transition-all duration-300" style="width: 0%"></div>
|
|
||||||
<div data-validacao-target="barraManuais" class="h-3 bg-blue-500 transition-all duration-300" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
|
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
|
||||||
@@ -136,47 +143,61 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Ações em massa + filtro %>
|
<%# Barra de ações — uma linha só. O botão "Adicionar lançamento" (bloco
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3 flex flex-wrap items-center gap-3">
|
abaixo, que carrega os modais) entra NESTA mesma faixa via flex `order`,
|
||||||
<label class="flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none">
|
sem precisar mover os modais de lugar. %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl px-3 py-2 mb-2 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<label class="order-1 flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none h-11 whitespace-nowrap">
|
||||||
<input type="checkbox" data-validacao-target="selecionarTodos"
|
<input type="checkbox" data-validacao-target="selecionarTodos"
|
||||||
data-action="change->validacao#selecionarTodos"
|
data-action="change->validacao#selecionarTodos"
|
||||||
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
|
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
|
||||||
Selecionar todos
|
Selecionar
|
||||||
|
<%# Contador aparece só quando há seleção — é o retorno visual de que os
|
||||||
|
pilares saíram do estado inerte. %>
|
||||||
|
<span data-validacao-target="chipSelecao"
|
||||||
|
class="hidden px-2 py-0.5 rounded-full bg-orange-500 text-black text-xs font-bold"><span data-validacao-target="contadorSelecao">0</span></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-wrap border-l border-[#2a2a2a] pl-3">
|
<%# Pilares SEMPRE visíveis: são a ação principal da tela e o ADM precisa
|
||||||
<span class="text-gray-500 text-sm">Aplicar nos selecionados <span class="text-gray-600">(clique de novo p/ remover)</span>:</span>
|
vê-los para saber o que pode fazer. Sem seleção eles ficam esmaecidos
|
||||||
|
(não escondidos) — a versão anterior os removia do DOM e obrigava a
|
||||||
|
descobrir que existiam depois de marcar algo. %>
|
||||||
|
<div data-validacao-target="grupoPilares"
|
||||||
|
class="order-2 flex items-center gap-1.5 flex-wrap opacity-40 transition-opacity"
|
||||||
|
title="Marque ao menos uma entrega para aplicar em massa">
|
||||||
<button data-action="click->validacao#marcarSelecionados" data-tipo="entrega_normal"
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="entrega_normal"
|
||||||
class="bg-orange-500 text-black font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Normal</button>
|
class="bg-orange-500 text-black font-bold px-3 h-11 rounded-lg text-xs">Normal</button>
|
||||||
<button data-action="click->validacao#marcarSelecionados" data-tipo="retirada"
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="retirada"
|
||||||
class="bg-orange-800 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Retirada</button>
|
class="bg-orange-800 text-white font-bold px-3 h-11 rounded-lg text-xs">Retirada</button>
|
||||||
<button data-action="click->validacao#marcarSelecionados" data-tipo="bonus"
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="bonus"
|
||||||
class="bg-white text-black font-bold px-3 py-2 rounded-lg text-xs border border-orange-500 min-h-[44px]">Bônus</button>
|
class="bg-white text-black font-bold px-3 h-11 rounded-lg text-xs border border-orange-500">Bônus</button>
|
||||||
<button data-action="click->validacao#marcarSelecionados" data-tipo="desconto"
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="desconto"
|
||||||
class="bg-black text-white font-bold px-3 py-2 rounded-lg text-xs border border-gray-700 min-h-[44px]">Desconto</button>
|
class="bg-black text-white font-bold px-3 h-11 rounded-lg text-xs border border-gray-700">Desconto</button>
|
||||||
<button data-action="click->validacao#marcarSelecionados" data-tipo="extraordinaria"
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="extraordinaria"
|
||||||
class="bg-purple-600 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Extra</button>
|
class="bg-purple-600 text-white font-bold px-3 h-11 rounded-lg text-xs">Extra</button>
|
||||||
|
<button type="button" data-action="validacao#limparSelecao" data-validacao-target="btnLimpar"
|
||||||
|
class="hidden text-gray-400 hover:text-white text-xs underline px-1">limpar</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="order-4 ml-auto flex items-center gap-2">
|
||||||
<%# Oculta/mostra o resumo lateral (mais largura para a lista) %>
|
<%# Oculta/mostra o resumo lateral (mais largura para a lista) %>
|
||||||
<button type="button" data-action="validacao#toggleResumo"
|
<button type="button" data-action="validacao#toggleResumo"
|
||||||
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg whitespace-nowrap">
|
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 h-11 rounded-lg whitespace-nowrap">
|
||||||
<%= icone :dashboard %> <span data-validacao-target="btnResumoLabel">Ocultar resumo</span>
|
<%= icone :dashboard %> <span data-validacao-target="btnResumoLabel">Ocultar resumo</span>
|
||||||
</button>
|
</button>
|
||||||
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
|
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
|
||||||
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculos_sel.presence,
|
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculos_sel.presence,
|
||||||
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
|
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
|
||||||
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 py-2 rounded-lg' %>
|
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 h-11 rounded-lg flex items-center' %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# Caixa de ferramentas — concentra os lançamentos avulsos (registro manual de
|
<%# Caixa de ferramentas — concentra os lançamentos avulsos (registro manual de
|
||||||
entrega e entrega de termo) num único botão com menu suspenso. Os modais e
|
entrega e entrega de termo) num único botão com menu suspenso. Os modais e
|
||||||
controllers existentes são reaproveitados; só o gatilho mudou. %>
|
controllers existentes são reaproveitados; só o gatilho mudou.
|
||||||
|
Fica DENTRO da barra de ações (order-2) para não custar uma linha própria;
|
||||||
|
os modais são `fixed`, então não afetam o layout da faixa. %>
|
||||||
<% unless @consolidacao.finalizada? && current_user.operador? %>
|
<% unless @consolidacao.finalizada? && current_user.operador? %>
|
||||||
<div class="relative mb-3"
|
<div class="order-3 relative"
|
||||||
data-controller="ferramentas apontamento-manual apontamento-termo"
|
data-controller="ferramentas apontamento-manual apontamento-termo"
|
||||||
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
data-apontamento-termo-apontar-termo-url-value="<%= apontar_termo_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-apontamento-termo-apontar-termo-url-value="<%= apontar_termo_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
@@ -184,7 +205,7 @@
|
|||||||
|
|
||||||
<%# Botão único + menu suspenso %>
|
<%# Botão único + menu suspenso %>
|
||||||
<button type="button" data-action="ferramentas#toggle"
|
<button type="button" data-action="ferramentas#toggle"
|
||||||
class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-[#3a3a3a] hover:border-orange-500 font-semibold px-4 py-3 rounded-lg min-h-[48px]">
|
class="inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-[#3a3a3a] hover:border-orange-500 font-semibold px-4 h-11 rounded-lg text-sm whitespace-nowrap">
|
||||||
<%= icone :adicionar %> Adicionar lançamento
|
<%= icone :adicionar %> Adicionar lançamento
|
||||||
<%= icone :expandir, cor: 'text-gray-500', espaco: false %>
|
<%= icone :expandir, cor: 'text-gray-500', espaco: false %>
|
||||||
</button>
|
</button>
|
||||||
@@ -347,6 +368,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%# Fim da barra de ações %>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %>
|
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %>
|
||||||
|
|
||||||
@@ -375,6 +399,7 @@
|
|||||||
|
|
||||||
<%# Checkbox seleção em massa %>
|
<%# Checkbox seleção em massa %>
|
||||||
<input type="checkbox" data-validacao-target="checkbox" value="<%= e.tracking_id %>"
|
<input type="checkbox" data-validacao-target="checkbox" value="<%= e.tracking_id %>"
|
||||||
|
data-action="change->validacao#atualizarSelecao"
|
||||||
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
|
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
|
||||||
|
|
||||||
<%# Dados da entrega %>
|
<%# Dados da entrega %>
|
||||||
|
|||||||
@@ -71,28 +71,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# ── Filtros ──────────────────────────────────────────── %>
|
<%# ── Filtros ──────────────────────────────────────────── %>
|
||||||
|
<%# Filtros em flex-wrap, NÃO em grade de 6 colunas fixas.
|
||||||
|
Motivo: os breakpoints do Tailwind (md:, lg:…) enxergam a JANELA, mas o
|
||||||
|
conteúdo perde 256px para a sidebar (`main.md:ml-64`). A mesma janela de
|
||||||
|
1280px tem duas larguras conforme o menu esteja aberto ou recolhido, então
|
||||||
|
`md:grid-cols-6` espremia os campos a ~150px e o texto do botão "Filtrar"
|
||||||
|
vazava para fora do fundo. Com flex-wrap + largura mínima, cada campo mantém
|
||||||
|
tamanho legível e a linha quebra sozinha conforme o espaço REAL. %>
|
||||||
<%= form_with url: consolidacoes_path, method: :get,
|
<%= form_with url: consolidacoes_path, method: :get,
|
||||||
class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-6 gap-3' do |f| %>
|
class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 flex flex-wrap items-center gap-3' do |f| %>
|
||||||
<%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…',
|
<%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…',
|
||||||
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %>
|
class: 'flex-1 basis-48 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %>
|
||||||
|
|
||||||
<%= f.select :status,
|
<%= f.select :status,
|
||||||
options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]),
|
options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]),
|
||||||
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
{}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
<%= f.select :pagamento,
|
<%= f.select :pagamento,
|
||||||
options_for_select([['Pagamento (todos)', ''], ['Pagas', 'pago'], ['Parciais', 'parcial'], ['Pendentes', 'pendente']], params[:pagamento]),
|
options_for_select([['Pagamento (todos)', ''], ['Pagas', 'pago'], ['Parciais', 'parcial'], ['Pendentes', 'pendente']], params[:pagamento]),
|
||||||
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
{}, class: 'flex-1 basis-44 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
<%= f.date_field :inicio, value: params[:inicio],
|
<%= f.date_field :inicio, value: params[:inicio],
|
||||||
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
<%= f.date_field :fim, value: params[:fim],
|
<%= f.date_field :fim, value: params[:fim],
|
||||||
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<%# shrink-0 + whitespace-nowrap: os botões nunca são espremidos a ponto de
|
||||||
<%= f.submit 'Filtrar', class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer' %>
|
cortar o texto — se faltar espaço, a linha quebra e eles descem inteiros. %>
|
||||||
<%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg' %>
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<%= f.submit 'Filtrar', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer px-5 py-2.5 whitespace-nowrap' %>
|
||||||
|
<%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -117,15 +117,21 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# CARDS GRANDES — KPIs %>
|
<%# CARDS GRANDES — KPIs
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
auto-fit + minmax em vez de `xl:grid-cols-5`: o breakpoint xl olha a JANELA
|
||||||
|
(1280px), mas com a sidebar aberta sobram ~960px de conteúdo — 5 colunas de
|
||||||
|
~176px, e "R$ 82.692,00" em text-4xl era cortado pelo overflow-hidden do
|
||||||
|
card. Com auto-fit o número de colunas cai sozinho quando o menu está
|
||||||
|
aberto e sobe quando ele é recolhido, sem depender de breakpoint. %>
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
|
||||||
|
|
||||||
<%# Card 1: Valor estimado %>
|
<%# Card 1: Valor estimado — sempre 2 faixas: é o maior número da tela %>
|
||||||
<div class="sm:col-span-2 xl:col-span-1 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
|
<div class="sm:col-span-2 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-transparent"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-transparent"></div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<p class="text-orange-100 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, cor: nil, espaco: false %> Valor Estimado</p>
|
<p class="text-orange-100 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, cor: nil, espaco: false %> Valor Estimado</p>
|
||||||
<p class="text-4xl font-black text-white mb-1">
|
<%# break-words: se ainda faltar espaço, quebra a linha em vez de cortar %>
|
||||||
|
<p class="text-3xl lg:text-4xl font-black text-white mb-1 break-words">
|
||||||
<%= moeda(@valor_estimado) %>
|
<%= moeda(@valor_estimado) %>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-orange-200 text-sm">
|
<p class="text-orange-200 text-sm">
|
||||||
@@ -178,7 +184,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
|
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Custo total</p>
|
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Custo total</p>
|
||||||
<p class="text-3xl font-black text-white"><%= moeda(@fin_custo_total) %></p>
|
<p class="text-3xl font-black text-white"><%= moeda(@fin_custo_total) %></p>
|
||||||
@@ -445,7 +451,7 @@
|
|||||||
<% if @por_operacao.any? %>
|
<% if @por_operacao.any? %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :operacao, espaco: false %> Entregas por Local</h2>
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :operacao, espaco: false %> Entregas por Local</h2>
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
|
||||||
<% total_op = @por_operacao.values.sum.to_f %>
|
<% total_op = @por_operacao.values.sum.to_f %>
|
||||||
<% @por_operacao.each do |local, qtd| %>
|
<% @por_operacao.each do |local, qtd| %>
|
||||||
<div class="bg-[#0a0a0a] rounded-xl p-4 border border-white/5 text-center">
|
<div class="bg-[#0a0a0a] rounded-xl p-4 border border-white/5 text-center">
|
||||||
|
|||||||
198
bin/sondar_busca_nf
Executable file
198
bin/sondar_busca_nf
Executable file
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
#
|
||||||
|
# Descobre se a API do SimpliRoute aceita buscar visita por NF e/ou por período
|
||||||
|
# — a doc oficial só documenta `?planned_date=` de UM dia.
|
||||||
|
#
|
||||||
|
# ── RESULTADO DA RODADA DE 21/07/2026 (contra a API real) ───────────────────
|
||||||
|
# ✔ `&search=<NF>` FUNCIONA (não documentado): dia inteiro cai de ~3,8 MB/9 s
|
||||||
|
# para ~1 KB/0,6 s. É o que a tela usa hoje.
|
||||||
|
# ✘ `reference`, `reference_id`, `q`, `title`, `reference__*` — IGNORADOS em
|
||||||
|
# silêncio (200 + dia inteiro).
|
||||||
|
# ✘ Intervalo (`planned_date_from/to`, `since/until`, `__gte/__lte`,
|
||||||
|
# `date_from/to`) — TODOS ignorados: devolveram 2469, igual ao controle sem
|
||||||
|
# parâmetro nenhum.
|
||||||
|
# ✘ Sem `planned_date` a API devolve um conjunto padrão (~2469) que NÃO cobre
|
||||||
|
# o histórico — `?search=<NF>` sozinho escondeu a visita mais antiga da NF.
|
||||||
|
# ⇒ `planned_date` continua obrigatório; a busca por NF é dia a dia + search.
|
||||||
|
#
|
||||||
|
# Rode de novo se desconfiar que a API mudou.
|
||||||
|
#
|
||||||
|
# SÓ LEITURA: o script faz apenas GET/OPTIONS. Não altera nada.
|
||||||
|
#
|
||||||
|
# Rode NO SERVIDOR (é lá que vive o SIMPLIROUTE_TOKEN):
|
||||||
|
#
|
||||||
|
# SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17
|
||||||
|
# SIMPLIROUTE_TOKEN=xxx bin/sondar_busca_nf --nf 82891 --data 2026-07-17 --sem-data
|
||||||
|
#
|
||||||
|
# --data deve ser um dia em que a NF EXISTE (é a referência de comparação).
|
||||||
|
# --sem-data testa a consulta sem `planned_date` nenhum; pode ser lenta ou vir
|
||||||
|
# gigante, por isso fica de fora por padrão.
|
||||||
|
#
|
||||||
|
# ⚠️ Por que comparar contagens: a API é Django REST. Parâmetro que o backend
|
||||||
|
# não registra é IGNORADO em silêncio — devolve 200 e a lista inteira. Então
|
||||||
|
# "voltou 200 com resultados" NÃO prova que filtrou. A prova é o filtro DIMINUIR
|
||||||
|
# o resultado do dia e sobrar só a NF pedida.
|
||||||
|
|
||||||
|
require 'net/http'
|
||||||
|
require 'json'
|
||||||
|
require 'uri'
|
||||||
|
require 'date'
|
||||||
|
|
||||||
|
BASE = ENV.fetch('SIMPLIROUTE_BASE_URL', 'https://api.simpliroute.com')
|
||||||
|
TOKEN = ENV['SIMPLIROUTE_TOKEN'].to_s
|
||||||
|
|
||||||
|
def sair(msg)
|
||||||
|
warn msg
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty?
|
||||||
|
|
||||||
|
nf = nil
|
||||||
|
data = nil
|
||||||
|
sem_data = false
|
||||||
|
ARGV.each_with_index do |a, i|
|
||||||
|
nf = ARGV[i + 1] if a == '--nf'
|
||||||
|
data = ARGV[i + 1] if a == '--data'
|
||||||
|
sem_data = true if a == '--sem-data'
|
||||||
|
end
|
||||||
|
sair('Uso: bin/sondar_busca_nf --nf <numero> --data <YYYY-MM-DD> [--sem-data]') if nf.to_s.empty? || data.to_s.empty?
|
||||||
|
|
||||||
|
def requisicao(caminho, metodo: :get)
|
||||||
|
uri = URI.join(BASE, caminho)
|
||||||
|
req = metodo == :options ? Net::HTTP::Options.new(uri) : Net::HTTP::Get.new(uri)
|
||||||
|
req['Authorization'] = "Token #{TOKEN}"
|
||||||
|
req['Accept'] = 'application/json'
|
||||||
|
|
||||||
|
t0 = Time.now
|
||||||
|
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
||||||
|
open_timeout: 10, read_timeout: 120) { |h| h.request(req) }
|
||||||
|
corpo = begin
|
||||||
|
res.body.to_s.empty? ? nil : JSON.parse(res.body)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
res.body.to_s[0, 200]
|
||||||
|
end
|
||||||
|
[res.code.to_i, corpo, res.body.to_s.bytesize, Time.now - t0]
|
||||||
|
rescue StandardError => e
|
||||||
|
[0, "ERRO: #{e.class}: #{e.message}", 0, Time.now - t0]
|
||||||
|
end
|
||||||
|
|
||||||
|
# A resposta pode ser lista crua OU paginada ({count, next, results}). No caso
|
||||||
|
# paginado o `count` já é o total — é a informação mais barata que existe aqui.
|
||||||
|
def itens(corpo)
|
||||||
|
return corpo if corpo.is_a?(Array)
|
||||||
|
return Array(corpo['results']) if corpo.is_a?(Hash) && corpo.key?('results')
|
||||||
|
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def total(corpo, lista)
|
||||||
|
corpo.is_a?(Hash) && corpo['count'] ? corpo['count'] : lista.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def titulo(t)
|
||||||
|
puts "\n#{'─' * 70}\n#{t}\n#{'─' * 70}"
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Base: #{BASE} NF: #{nf} Data de referência: #{data}"
|
||||||
|
|
||||||
|
# ── 1. Linha de base: o dia inteiro, do jeito que a tela faz hoje ────────────
|
||||||
|
titulo("1. LINHA DE BASE · GET /v1/routes/visits/?planned_date=#{data}")
|
||||||
|
cod, corpo, bytes, seg = requisicao("/v1/routes/visits/?planned_date=#{data}")
|
||||||
|
sair("Falhou (HTTP #{cod}): #{corpo.inspect}") unless cod == 200
|
||||||
|
|
||||||
|
base_lista = itens(corpo)
|
||||||
|
base_total = total(corpo, base_lista)
|
||||||
|
base_ids = base_lista.map { |v| v['id'] }
|
||||||
|
da_nf = base_lista.select { |v| v['reference'].to_s == nf.to_s }
|
||||||
|
|
||||||
|
puts " HTTP 200 em #{seg.round(2)}s — #{bytes} bytes"
|
||||||
|
puts " visitas no dia .......... #{base_total}"
|
||||||
|
puts " paginado? ............... #{corpo.is_a?(Hash) ? "sim (count=#{corpo['count']}, next=#{corpo['next'].inspect})" : 'não (lista crua)'}"
|
||||||
|
puts " visitas com a NF #{nf} ... #{da_nf.size}"
|
||||||
|
da_nf.each { |v| puts " id=#{v['id']} tracking=#{v['tracking_id'].inspect} status=#{v['status']} data=#{v['planned_date']}" }
|
||||||
|
puts " ⚠️ tracking_id vem NULO na lista-por-data" if da_nf.any? && da_nf.all? { |v| v['tracking_id'].nil? }
|
||||||
|
sair("A NF #{nf} não aparece em #{data}. Passe uma data em que ela exista — sem isso não há como comparar.") if da_nf.empty?
|
||||||
|
|
||||||
|
# ── 2. Filtro por NF: o parâmetro DIMINUI o resultado do dia? ────────────────
|
||||||
|
# Se a contagem não mudar, o parâmetro foi ignorado (mesmo com HTTP 200).
|
||||||
|
titulo('2. FILTRO POR NF · <dia> + <param>=NF (diminuiu = filtro real)')
|
||||||
|
%w[reference reference_id search q title reference__exact reference__icontains].each do |param|
|
||||||
|
cod, corpo, _b, seg = requisicao("/v1/routes/visits/?planned_date=#{data}&#{param}=#{nf}")
|
||||||
|
lista = itens(corpo)
|
||||||
|
qtd = total(corpo, lista)
|
||||||
|
|
||||||
|
veredito =
|
||||||
|
if cod != 200 then "HTTP #{cod} — não aceito"
|
||||||
|
elsif qtd == base_total then 'IGNORADO (mesma contagem do dia inteiro)'
|
||||||
|
elsif qtd.zero? then 'aceito porém vazio — filtrou demais'
|
||||||
|
elsif lista.all? { |v| v['reference'].to_s == nf.to_s } then "★ FILTRA DE VERDADE — #{qtd} visita(s), todas da NF"
|
||||||
|
else "reduziu p/ #{qtd} mas veio NF de fora"
|
||||||
|
end
|
||||||
|
puts " #{param.ljust(22)} #{veredito} (#{seg.round(2)}s)"
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 3. Filtro por período ────────────────────────────────────────────────────
|
||||||
|
# Comparação: o intervalo <data>..<data+1> tem que devolver MAIS que só <data>.
|
||||||
|
dia2 = (Date.parse(data) + 1).to_s
|
||||||
|
titulo("3. FILTRO POR PERÍODO · #{data} a #{dia2} (> #{base_total} = intervalo respeitado)")
|
||||||
|
[
|
||||||
|
%w[planned_date_from planned_date_to],
|
||||||
|
%w[planned_date_after planned_date_before],
|
||||||
|
%w[planned_date__gte planned_date__lte],
|
||||||
|
%w[since until],
|
||||||
|
%w[start_date end_date],
|
||||||
|
%w[date_from date_to]
|
||||||
|
].each do |de, ate|
|
||||||
|
cod, corpo, _b, seg = requisicao("/v1/routes/visits/?#{de}=#{data}&#{ate}=#{dia2}")
|
||||||
|
lista = itens(corpo)
|
||||||
|
qtd = total(corpo, lista)
|
||||||
|
|
||||||
|
veredito =
|
||||||
|
if cod != 200 then "HTTP #{cod} — não aceito"
|
||||||
|
elsif qtd > base_total then "★ RESPEITA O INTERVALO — #{qtd} visitas (dia sozinho: #{base_total})"
|
||||||
|
elsif qtd == base_total then "mesma contagem do dia — inconclusivo/ignorado"
|
||||||
|
else "#{qtd} visitas — menos que o dia sozinho, estranho"
|
||||||
|
end
|
||||||
|
puts " #{"#{de}/#{ate}".ljust(38)} #{veredito} (#{seg.round(2)}s)"
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 4. OPTIONS: o DRF às vezes lista os filtros aceitos ──────────────────────
|
||||||
|
titulo('4. OPTIONS /v1/routes/visits/ (metadados do endpoint)')
|
||||||
|
cod, corpo, = requisicao('/v1/routes/visits/', metodo: :options)
|
||||||
|
if cod == 200 && corpo.is_a?(Hash)
|
||||||
|
puts " métodos ... #{corpo['renders'] ? corpo.slice('name', 'description', 'parses').inspect[0, 300] : corpo.keys.inspect}"
|
||||||
|
filtros = corpo['filters'] || corpo['filter_fields'] || corpo.dig('actions', 'GET')
|
||||||
|
puts " filtros ... #{filtros ? filtros.inspect[0, 500] : '(não declarados)'}"
|
||||||
|
else
|
||||||
|
puts " HTTP #{cod} — #{corpo.inspect[0, 200]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── 5. Sem planned_date nenhum — a pergunta direta ───────────────────────────
|
||||||
|
unless sem_data
|
||||||
|
puts "\n(Pulei o teste SEM planned_date. Rode com --sem-data para incluir — pode demorar ou vir gigante.)"
|
||||||
|
exit 0
|
||||||
|
end
|
||||||
|
|
||||||
|
titulo('5. SEM planned_date · a API devolve a NF inteira de uma vez?')
|
||||||
|
[
|
||||||
|
"/v1/routes/visits/?reference=#{nf}",
|
||||||
|
"/v1/routes/visits/?search=#{nf}",
|
||||||
|
'/v1/routes/visits/'
|
||||||
|
].each do |caminho|
|
||||||
|
cod, corpo, bytes, seg = requisicao(caminho)
|
||||||
|
lista = itens(corpo)
|
||||||
|
qtd = total(corpo, lista)
|
||||||
|
casam = lista.count { |v| v['reference'].to_s == nf.to_s }
|
||||||
|
|
||||||
|
puts " GET #{caminho}"
|
||||||
|
if cod == 200
|
||||||
|
puts " #{qtd} visita(s), #{casam} com a NF #{nf} — #{bytes} bytes em #{seg.round(2)}s"
|
||||||
|
datas = lista.select { |v| v['reference'].to_s == nf.to_s }.map { |v| v['planned_date'] }.uniq
|
||||||
|
puts " datas da NF: #{datas.inspect}" if datas.any?
|
||||||
|
puts ' ★ RESOLVE TUDO: a NF veio inteira, sem varrer dia a dia' if casam > 1 && casam == qtd
|
||||||
|
else
|
||||||
|
puts " HTTP #{cod} — #{corpo.inspect[0, 200]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -92,7 +92,8 @@ Rails.application.routes.draw do
|
|||||||
# Edição de lançamento do SimpliRoute (correção de status/motivo/comentário)
|
# Edição de lançamento do SimpliRoute (correção de status/motivo/comentário)
|
||||||
# — só admin. Grava direto na API do SimpliRoute + AuditoriaLog.
|
# — só admin. Grava direto na API do SimpliRoute + AuditoriaLog.
|
||||||
resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do
|
resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do
|
||||||
get :buscar # NF -> dados da visita (JSON)
|
get :buscar # NF -> lançamentos daquela NF (JSON; pode haver + de um)
|
||||||
|
get :carregar # visit_id -> card de um lançamento escolhido (JSON)
|
||||||
patch :atualizar # aplica a alteração na API
|
patch :atualizar # aplica a alteração na API
|
||||||
get :historico # histórico da visita na API (JSON, best-effort)
|
get :historico # histórico da visita na API (JSON, best-effort)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,3 +11,11 @@ set :environment, ENV.fetch("RAILS_ENV", "production")
|
|||||||
every "*/30 8-18 * * *" do
|
every "*/30 8-18 * * *" do
|
||||||
rake "historico:atualizar"
|
rake "historico:atualizar"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 01h — busca na API do SimpliRoute o número de série da base (aparelho) das
|
||||||
|
# visitas dos últimos 45 dias. É o único campo da planilha do cliente que o
|
||||||
|
# espelho não traz, e a API cobra ~9 s por DIA varrido: fora do horário
|
||||||
|
# comercial não atrapalha ninguém. Ver SincronizarSeriesAparelhoJob.
|
||||||
|
every "0 1 * * *" do
|
||||||
|
rake "simpli_route:series"
|
||||||
|
end
|
||||||
|
|||||||
26
db/migrate/20260722000001_create_series_aparelho.rb
Normal file
26
db/migrate/20260722000001_create_series_aparelho.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Guarda o "Número de Série da Base (Aparelho)" que o motorista registra no
|
||||||
|
# checkout do SimpliRoute.
|
||||||
|
#
|
||||||
|
# POR QUE UMA TABELA NOSSA: o campo existe como coluna no espelho
|
||||||
|
# (db_reem_simplerout_2026.numero_de_serie_da_base_aparelho), mas o processo que
|
||||||
|
# alimenta o espelho NUNCA o preenche — conferido em fev/mar/mai/jul/2026, 0 de
|
||||||
|
# ~6.000 linhas. O espelho é READ-ONLY (jamais escrever nele), então o job que
|
||||||
|
# busca o dado na API do SimpliRoute grava aqui, e a planilha do cliente faz o
|
||||||
|
# LEFT JOIN por tracking_id.
|
||||||
|
class CreateSeriesAparelho < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :series_aparelho do |t|
|
||||||
|
# tracking_id (SR...) é a chave do espelho — é por ele que a planilha casa.
|
||||||
|
t.string :tracking_id, null: false
|
||||||
|
t.string :reference_id # NF, só para conferência/depuração
|
||||||
|
t.string :numero_serie, null: false
|
||||||
|
t.date :planned_date # data varrida na API
|
||||||
|
t.datetime :sincronizado_em, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :series_aparelho, :tracking_id, unique: true
|
||||||
|
add_index :series_aparelho, :planned_date
|
||||||
|
end
|
||||||
|
end
|
||||||
42
lib/tasks/series_aparelho.rake
Normal file
42
lib/tasks/series_aparelho.rake
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# lib/tasks/series_aparelho.rake
|
||||||
|
#
|
||||||
|
# Sincroniza o "Número de Série da Base (Aparelho)" da API do SimpliRoute para a
|
||||||
|
# tabela series_aparelho (ver SincronizarSeriesAparelhoJob).
|
||||||
|
|
||||||
|
# Log padrão das tarefas do projeto (ver historico.rake): uma linha de início,
|
||||||
|
# uma de resultado, e o erro sobe para o cron registrar. É lambda (e não método)
|
||||||
|
# para não pendurar um `executar` global no Object.
|
||||||
|
executar = lambda do |&bloco|
|
||||||
|
puts "[#{Time.current}] Sincronizando número de série da base (SimpliRoute)..."
|
||||||
|
r = bloco.call
|
||||||
|
puts "[#{Time.current}] OK — #{r[:datas]} data(s) varrida(s), #{r[:visitas]} visita(s), #{r[:series]} série(s) gravada(s)."
|
||||||
|
puts "[#{Time.current}] AVISO — #{r[:falhas].size} data(s) sem resposta da API: #{r[:falhas].map { |f| f[:data] }.join(', ')}" if r[:falhas].present?
|
||||||
|
rescue StandardError => e
|
||||||
|
puts "[#{Time.current}] ERRO: #{e.message}"
|
||||||
|
Rails.logger.error("[simpli_route:series] #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace :simpli_route do
|
||||||
|
desc 'Sincroniza o número de série da base dos últimos 45 dias (cron diário)'
|
||||||
|
task series: :environment do
|
||||||
|
executar.call { SincronizarSeriesAparelhoJob.perform_now }
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'Backfill de UMA operação inteira — rake "simpli_route:series_operacao[gade_entregas_ubs_sudeste_jul_2026]"'
|
||||||
|
task :series_operacao, [:tabela] => :environment do |_t, args|
|
||||||
|
tabela = args[:tabela].to_s
|
||||||
|
if Operacao.valida?(tabela)
|
||||||
|
puts "[#{Time.current}] Backfill da operação #{tabela}..."
|
||||||
|
executar.call { SincronizarSeriesAparelhoJob.perform_now(operacao: tabela) }
|
||||||
|
else
|
||||||
|
puts "[#{Time.current}] ERRO: operação inválida (#{tabela.inspect})."
|
||||||
|
puts "Válidas: #{Operacao.nomes_validos.join(', ')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'Backfill por período — rake "simpli_route:series_periodo[2026-07-01,2026-07-31]"'
|
||||||
|
task :series_periodo, %i[de ate] => :environment do |_t, args|
|
||||||
|
executar.call { SincronizarSeriesAparelhoJob.perform_now(de: Date.parse(args[:de]), ate: Date.parse(args[:ate])) }
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user