From 04b78bc7de0f190b1823957d1d9f4a326bd4086e90b4b7564e3639fbed6d2b56 Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 24 Aug 2026 12:07:56 -0300 Subject: [PATCH 01/40] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20alguns=20valore?= =?UTF-8?q?s=20no=20dash=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 118 ++++++++++++++ app/controllers/dashboard_controller.rb | 7 + .../operacoes_dashboard_controller.rb | 28 +++- app/models/entrega.rb | 38 +++-- app/services/analytics/notas_fora_operacao.rb | 148 ++++++++++++++++++ app/services/analytics/operacao_metricas.rb | 118 +++++++++++--- app/views/dashboard/index.html.erb | 5 + .../_fora_operacao.html.erb | 81 ++++++++++ .../operacoes_dashboard/_painel.html.erb | 32 ++++ app/views/operacoes_dashboard/index.html.erb | 35 ++++- .../analytics/notas_fora_operacao_spec.rb | 67 ++++++++ .../analytics/operacao_metricas_spec.rb | 63 +++++++- 12 files changed, 690 insertions(+), 50 deletions(-) create mode 100644 app/services/analytics/notas_fora_operacao.rb create mode 100644 app/views/operacoes_dashboard/_fora_operacao.html.erb create mode 100644 spec/services/analytics/notas_fora_operacao_spec.rb diff --git a/README.md b/README.md index e3d1a16..36b65aa 100644 --- a/README.md +++ b/README.md @@ -2113,3 +2113,121 @@ docker compose exec app bundle exec rspec \ ``` + +--- + +
+🔱 Dashboard × OperaçÔes: por que os nĂșmeros nĂŁo batiam — notas x visitas + painel de avulsas (24/08/2026) + +> ⚠ **STATUS: implementado, ainda NÃO executado.** NĂŁo hĂĄ Ruby/Bundler nem Postgres na mĂĄquina de +> desenvolvimento — foi conferida a sintaxe de todos os `.rb` e `.erb` alterados. **A suĂ­te e a +> validação com dado real continuam pendentes** — roteiro no fim desta seção. + +### 🎯 O problema +Mesmo perĂ­odo filtrado, dois nĂșmeros diferentes: + +| | Dashboard financeiro | Dashboard de OperaçÔes | +|---|---|---| +| Total | 4977 | 4973 | +| Entregues / Sucesso | 4852 | 4851 | +| Falhadas / Recusas | 124 | 122 | +| Pendentes | 1 | 0 | + +NĂŁo era arredondamento: **as duas telas contam coisas diferentes**, e nada na interface dizia isso. + +- O **financeiro** conta **visitas** (idas ao local). É o recorte certo lĂĄ, porque Ă© por ida que o + motorista recebe — `Entrega.contar_atendidas` conta linhas, e a consolidação paga em cima disso. +- **OperaçÔes** conta **notas fiscais** (Ășltimo status de cada NF). É o recorte certo aqui, porque + Ă© o que o cliente paga e o que confere nos documentos fĂ­sicos — o mesmo critĂ©rio da aba ENTREGAS + da planilha entregue (`Analytics::PlanilhaEntregas`). + +Uma NF que falhou dia 10 e foi entregue dia 12 vale **2 no financeiro e 1 em OperaçÔes**. Correto +nos dois — mas invisĂ­vel. + +### 🐛 TrĂȘs defeitos reais por trĂĄs disso + +**1. O dedup rodava sobre a tabela inteira, nĂŁo sobre o perĂ­odo.** O `ROW_NUMBER() ... rn = 1` +ficava numa CTE **antes** do filtro de data. Se o Ășltimo checkout de uma NF era **posterior** ao fim +do perĂ­odo, a visita que aconteceu **dentro** do perĂ­odo sumia da contagem do mĂȘs. Subcontagem +silenciosa em todo fechamento. Agora o dedup acontece em `#linhas`, **depois** do perĂ­odo e do +cross-filter. + +**2. O dedup escondia todo o insucesso reentregue.** NF que falhou duas vezes antes de entregar +aparecia como 100% de sucesso, e o motivo sumia do "Índices de falha". + +**3. O perĂ­odo nem era aplicado no modo Operação.** `montar([@operacao], ...)` era chamado **sem +`inicio:`/`fim:`** e o seletor de data sĂł aparecia no modo Global — comparar as duas telas "no mesmo +perĂ­odo" era literalmente impossĂ­vel. + +### 🆕 O que mudou na tela + +**OperaçÔes — camada "Visitas ao local"** (abaixo dos 4 cards): visitas realizadas, retentativas, +insucessos por visita e "entregues na 2ÂȘ ida ou mais". Os 4 cards de cima seguem contando **notas**. + +**OperaçÔes — painel "Notas fora da operação"**: NFs entregues no perĂ­odo que **nĂŁo estĂŁo em nenhuma +planilha `gade_entregas_*`** — os planos avulsos e de inclusĂŁo. Contavam no financeiro e o +`INNER JOIN` com a tabela da operação as descartava aqui. Agora aparecem com NF, plano/tĂ­tulo, +motorista, unidade, data, resultado e motivo, com quebra por plano de origem. + +**OperaçÔes — perĂ­odo no modo Operação**: o seletor passa a valer tambĂ©m aqui, mas **sĂł quando o +operador escolhe uma faixa** (`inicio`/`fim` na URL). Sem escolha, o recorte segue sendo a operação +inteira — senĂŁo abrir uma operação de meses atrĂĄs cairia no mĂȘs corrente e mostraria zero. BotĂŁo +**"Operação inteira"** volta ao recorte natural. + +**Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas". + +### ⚙ Pontos nĂŁo-Ăłbvios + +**Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma +linha por NF (a Ășltima visita). Todos os KPIs, o donut, os motivos, o mapa e a tabela espelho +continuam saindo de `#linhas` — ou seja, **a tela segue batendo com a planilha do cliente**. SĂł a +faixa nova lĂȘ `#visitas`. + +**`uniq` por `tracking_id`.** Sem a CTE, se a mesma `nota_fiscal` estiver repetida dentro de uma +tabela de operação (ou em duas tabelas do UNION global), o `INNER JOIN` devolvia a **mesma visita** +mais de uma vez. Agora colapsa. + +**Filtro de conta unificado.** `Entrega.condicao_conta_sql` nasceu para as queries cruas de +`Analytics` usarem exatamente o mesmo recorte de `DB_EXISTING_ACCOUNT_ID` do scope +`da_conta_gade` — antes o dashboard filtrava conta e OperaçÔes nĂŁo. + +**`NOT IN` com `NULL` devolve zero linhas.** Cada `SELECT` da uniĂŁo em `NotasForaOperacao` filtra +`nota_fiscal IS NOT NULL`; sem isso um Ășnico NULL numa planilha deixaria o painel vazio para sempre. +Tem spec para isso. + +### 📂 Arquivos +``` +app/models/entrega.rb (contas_gade + condicao_conta_sql) +app/services/analytics/operacao_metricas.rb (visitas x linhas; dedup pĂłs-filtro; conta) +app/services/analytics/notas_fora_operacao.rb (NOVO — avulsas/inclusĂŁo) +app/controllers/operacoes_dashboard_controller.rb (perĂ­odo no modo Operação; @fora_operacao) +app/controllers/dashboard_controller.rb (@notas_atendidas) +app/views/operacoes_dashboard/_painel.html.erb (faixa "Visitas ao local") +app/views/operacoes_dashboard/_fora_operacao.html.erb (NOVO — painel de avulsas) +app/views/operacoes_dashboard/index.html.erb (seletor de perĂ­odo + render do painel) +app/views/dashboard/index.html.erb (linha "N notas fiscais") +spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa) +spec/services/analytics/notas_fora_operacao_spec.rb (NOVO) +``` + +> **Sem migration e sem gem nova** — model, services, controllers, views e specs. + +### ⏳ Pendente — roteiro +```bash +# 1. SuĂ­te (nĂŁo pĂŽde ser executada aqui — sem Ruby/Bundler local) +docker compose exec app bundle exec rspec \ + spec/services/analytics/operacao_metricas_spec.rb \ + spec/services/analytics/notas_fora_operacao_spec.rb \ + spec/models/entrega_spec.rb + +# 2. Conferir a coluna que carrega o nome do PLANO ("(Avulsa)", "INCLUSÃO"). +# NotasForaOperacao::COLUNAS_PLANO tenta title, notes, comments, route_id nessa ordem. +# Se o painel mostrar "SEM PLANO IDENTIFICADO", a coluna certa Ă© outra: +docker compose exec app bin/rails runner 'puts Entrega.column_names.sort' + +# 3. Com dado real, no mesmo perĂ­odo nas duas telas: +# financeiro "N notas fiscais" == OperaçÔes Global "Total de Entregas" +# financeiro "Total Entregas" == OperaçÔes "Visitas ao local" + notas fora da operação +``` + +
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8cba40d..eca9195 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -102,6 +102,13 @@ class DashboardController < ApplicationController @entregas_pendentes = pendentes.count @total_entregas = @entregas_atendidas + @entregas_pendentes + # Quantas NOTAS FISCAIS distintas estĂŁo por trĂĄs das visitas atendidas. É o + # nĂșmero que o dashboard de OperaçÔes mostra (e que o cliente paga/confere + # nos documentos); aqui contamos VISITAS, porque Ă© por ida ao local que o + # motorista recebe. Exibir os dois lado a lado evita a leitura de que uma das + # telas estĂĄ errada quando elas divergem — a diferença sĂŁo retentativas. + @notas_atendidas = atendidas.distinct.count(:reference_id) + # ConfiguraçÔes de preço config = Configuracao.mapa_de_precos diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index 6e79101..ca48bd0 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -16,6 +16,7 @@ class OperacoesDashboardController < ApplicationController authorize :dashboard, :operacoes? @periodo_inicio, @periodo_fim = periodo_selecionado + @periodo_explicito = periodo_explicito? @modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao' @operacoes_agrupadas = Operacao.agrupadas_por_mes @tabelas_validas = Operacao.nomes_validos @@ -26,6 +27,7 @@ class OperacoesDashboardController < ApplicationController when 'global' # Global: agrega TODAS as operaçÔes dentro da faixa de datas escolhida. @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) + @fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) when 'comparar' # Comparar: cada operação inteira (sem filtro de data — sĂł vale no Global). @op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0] @@ -33,9 +35,14 @@ class OperacoesDashboardController < ApplicationController @metricas_a = montar([@op_a]) if @op_a @metricas_b = montar([@op_b]) if @op_b else - # Operação Ășnica: a operação inteira (a data vem dos prĂłprios dados). + # Operação Ășnica. Sem datas na URL o recorte Ă© a operação inteira (o + # perĂ­odo vem dos prĂłprios dados) — Ă© o que mantĂ©m utilizĂĄvel abrir uma + # operação de meses atrĂĄs. Quando o operador ESCOLHE um perĂ­odo, ele passa + # a valer aqui tambĂ©m: sem isso, comparar esta tela com o dashboard + # financeiro "no mesmo perĂ­odo" era impossĂ­vel, porque a data era ignorada. @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first - @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro, **faixa_ativa) if @operacao + @fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) if @periodo_explicito end end @@ -46,6 +53,7 @@ class OperacoesDashboardController < ApplicationController authorize :dashboard, :operacoes? @periodo_inicio, @periodo_fim = periodo_selecionado + @periodo_explicito = periodo_explicito? @modo = params[:modo] == 'global' ? 'global' : 'operacao' @operacoes_agrupadas = Operacao.agrupadas_por_mes @tabelas_validas = Operacao.nomes_validos @@ -56,7 +64,7 @@ class OperacoesDashboardController < ApplicationController @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) else @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first - @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro, **faixa_ativa) if @operacao end montar_tabela_espelho if @metricas @@ -100,6 +108,20 @@ class OperacoesDashboardController < ApplicationController @espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || [] end + # PerĂ­odo escolhido pelo operador (veio na URL) x default da tela. SĂł o + # explĂ­cito Ă© aplicado no modo Operação — ver o comentĂĄrio no #index. + def periodo_explicito? + params[:inicio].present? || params[:fim].present? + end + + # Faixa a repassar para as mĂ©tricas no modo Operação: vazia quando o operador + # nĂŁo escolheu perĂ­odo (aĂ­ o recorte Ă© a operação inteira). + def faixa_ativa + return {} unless @periodo_explicito + + { inicio: @periodo_inicio, fim: @periodo_fim } + end + def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data) end diff --git a/app/models/entrega.rb b/app/models/entrega.rb index 3882316..d7b67d4 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -92,19 +92,39 @@ class Entrega < ApplicationRecord # Aceita: "95907" (uma) | "95907,12345" (vĂĄrias) | "all" ou vazio (sem filtro). # Use "95907," (vĂ­rgula no fim) para incluir tambĂ©m registros de conta vazia/NULL. scope :da_conta_gade, -> { - contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip - if contas.empty? || contas.casecmp?('all') - all - else - valores = contas.split(',', -1).map(&:strip) - # Token vazio (ex.: "95907,") → inclui tambĂ©m as linhas sem conta (NULL). - valores << nil if valores.any?(&:empty?) - where(account_id: valores) - end + valores = contas_gade + valores.nil? ? all : where(account_id: valores) } # ── MĂ©todos de classe ──────────────────────────────────────── + # Contas aceitas (DB_EXISTING_ACCOUNT_ID), normalizadas. `nil` = sem filtro + # ("all"/vazio); um token vazio ("95907,") inclui tambĂ©m as linhas sem conta. + def self.contas_gade + contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip + return nil if contas.empty? || contas.casecmp?('all') + + valores = contas.split(',', -1).map(&:strip) + valores << nil if valores.any?(&:empty?) + valores.uniq + end + + # A MESMA condição do scope :da_conta_gade, como fragmento SQL — para as + # queries cruas de Analytics (que montam UNION por operação e nĂŁo passam pelo + # ActiveRecord) usarem exatamente o mesmo recorte de conta do dashboard. + # Devolve nil quando nĂŁo hĂĄ filtro. `apelido` Ă© o alias da tabela na query. + def self.condicao_conta_sql(apelido = table_name) + valores = contas_gade + return nil if valores.nil? + + coluna = "#{connection.quote_table_name(apelido)}.account_id" + listadas = valores.compact + partes = [] + partes << sanitize_sql_array(["#{coluna} IN (?)", listadas]) if listadas.any? + partes << "#{coluna} IS NULL" if valores.include?(nil) + "(#{partes.join(' OR ')})" + end + # Lista motoristas Ășnicos (para selects, consolidaçÔes) def self.motoristas_ativos(inicio: nil, fim: nil) base = da_conta_gade diff --git a/app/services/analytics/notas_fora_operacao.rb b/app/services/analytics/notas_fora_operacao.rb new file mode 100644 index 0000000..ebbe1e9 --- /dev/null +++ b/app/services/analytics/notas_fora_operacao.rb @@ -0,0 +1,148 @@ +# app/services/analytics/notas_fora_operacao.rb +# +# NFs que o motorista entregou no perĂ­odo mas que NÃO estĂŁo em nenhuma planilha +# de operação (`gade_entregas_*`) — as notas que entram por plano avulso ou de +# inclusĂŁo, fora do carregamento original do cliente. +# +# Elas contam no dashboard financeiro (o motorista foi ao local e recebe por +# isso) e SUMIAM do dashboard de operaçÔes, porque lĂĄ o INNER JOIN com a tabela +# da operação simplesmente as descarta. Era metade da divergĂȘncia entre as duas +# telas — agora aparece como painel prĂłprio em vez de virar diferença silenciosa. +# +# SEGURANÇA: os nomes das tabelas passam pela whitelist (Operacao.nomes_validos, +# que lĂȘ o catĂĄlogo) + quote_table_name. Bases SOMENTE LEITURA. +module Analytics + class NotasForaOperacao + # Teto da listagem na tela (os totais continuam contando tudo). + LIMITE = 300 + + # Colunas do espelho que podem carregar o nome do PLANO/rota de origem + # ("(Avulsa)", "INCLUSÃO"...). Nem toda base tem todas — as ausentes viram + # NULL, mesmo padrĂŁo de selects_gade em OperacaoMetricas. Whitelist fixa: + # nada aqui vem do usuĂĄrio. + COLUNAS_PLANO = %w[title notes comments route_id].freeze + + def initialize(inicio:, fim:) + @inicio = inicio&.to_date + @fim = fim&.to_date + end + + # Visitas cruas (pode haver mais de uma por NF). + def visitas + @visitas ||= carregar + end + + # Uma linha por NF: a Ășltima visita dela. Mesmo critĂ©rio de OperacaoMetricas. + def linhas + @linhas ||= visitas.group_by { |r| r['reference_id'].to_s } + .values + .map { |vs| vs.max_by { |r| ordem_visita(r) } } + .sort_by { |r| r['checkout'].to_s } + .reverse + end + + def total + linhas.size + end + + def entregues + linhas.count { |r| r['status'] == 'completed' } + end + + def nao_entregues + linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) } + end + + def pendentes + total - entregues - nao_entregues + end + + def any? + total.positive? + end + + # Agrupamento por plano de origem, quando a base tiver alguma das colunas de + # COLUNAS_PLANO preenchida. Serve para separar "(Avulsa)" de "INCLUSÃO". + def por_plano + linhas.group_by { |r| plano(r) } + .map { |nome, rows| { nome: nome, total: rows.size } } + .sort_by { |h| -h[:total] } + end + + # RĂłtulo do plano de uma linha: primeira coluna de COLUNAS_PLANO preenchida. + def plano(registro) + COLUNAS_PLANO.each do |coluna| + valor = registro[coluna].to_s.strip + return valor if valor.present? + end + 'SEM PLANO IDENTIFICADO' + end + + def listagem + linhas.first(LIMITE) + end + + def truncada? + total > LIMITE + end + + private + + def ordem_visita(registro) + t = registro['checkout'].presence&.to_time + [t ? 1 : 0, t || Time.at(0)] + rescue ArgumentError, TypeError + [0, Time.at(0)] + end + + def carregar + tabelas = Operacao.nomes_validos + return [] if tabelas.empty? + + conn = ActiveRecord::Base.connection + rastreio = conn.quote_table_name(Entrega.table_name) + conta = Entrega.condicao_conta_sql('r') + + # `nota_fiscal IS NOT NULL` Ă© OBRIGATÓRIO: um Ășnico NULL na subquery faz o + # NOT IN devolver ZERO linhas (semĂąntica de trĂȘs valores do SQL) e o painel + # apareceria vazio para sempre. + conhecidas = tabelas.map do |t| + "SELECT nota_fiscal FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IS NOT NULL" + end.join(' UNION ') + + sql = <<~SQL + SELECT r.tracking_id, r.reference_id, r.driver, r.vehicle, r.status, r.observation, + r.contact_name, r.address, r.checkout, r.planned_date, + #{selects_plano(conn)} + FROM #{rastreio} r + WHERE r.reference_id IS NOT NULL + #{conta ? "AND #{conta}" : ''} + #{filtro_periodo(conn)} + AND r.reference_id::text NOT IN (#{conhecidas}) + SQL + + conn.select_all(sql).to_a + end + + # Colunas de plano que existirem de fato; as demais viram NULL com o mesmo + # alias, para a leitura da linha nĂŁo precisar saber quais existem. + def selects_plano(conn) + existentes = conn.columns(Entrega.table_name).map(&:name) + COLUNAS_PLANO.map do |coluna| + existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}" + end.join(', ') + end + + # Mesmo recorte de OperacaoMetricas#filtro_periodo: atendidas pela data real + # (checkout); em aberto (sem checkout) pela data planejada. + def filtro_periodo(conn) + return '' unless @inicio && @fim + + ini = conn.quote(@inicio) + fim_excl = conn.quote(@fim + 1) + fim_dia = conn.quote(@fim.end_of_day) + "AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \ + " OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))" + end + end +end diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb index e05bf31..a2304b0 100644 --- a/app/services/analytics/operacao_metricas.rb +++ b/app/services/analytics/operacao_metricas.rb @@ -2,12 +2,20 @@ # # NĂșcleo de dados do "Dashboard de OperaçÔes". # -# Espelha a query de gestĂŁo do cliente: pega o ÚLTIMO status de cada NF em -# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz -# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por -# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os -# painĂ©is (KPIs, insucessos %, Ă­ndices de falha, status, motoristas, STS, por dia -# e o mapa de calor). +# Espelha a query de gestĂŁo do cliente: casa db_reem_simplerout_2026 com a(s) +# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e +# agrega em Ruby para alimentar os painĂ©is (KPIs, insucessos %, Ă­ndices de falha, +# status, motoristas, STS, por dia e o mapa de calor). +# +# DUAS CAMADAS, de propĂłsito: +# #visitas — uma linha por ida do motorista ao local. É o que o dashboard +# financeiro conta (Entrega.contar_atendidas) e o que se paga ao +# motorista. +# #linhas — uma linha por NOTA FISCAL (a Ășltima visita de cada uma). É o que +# o cliente paga, o que confere com os documentos fĂ­sicos e com a +# aba ENTREGAS da planilha. TODOS os KPIs desta tela saem daqui. +# Os dois nĂșmeros sĂł coincidem quando nenhuma NF precisou de segunda ida; a +# diferença aparece na tela como "retentativas" em vez de ficar escondida. # # SEGURANÇA: os nomes das tabelas de operação sĂł entram no SQL depois de passar # pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrĂŁo @@ -36,15 +44,30 @@ module Analytics @tabelas.map { |t| Operacao.label(t) }.join(', ') end - # Linhas apĂłs o cross-filter — base de TODAS as agregaçÔes/KPIs. - def linhas - @linhas ||= begin + # VISITAS apĂłs o cross-filter: uma linha por passagem do motorista. Uma mesma + # NF pode ter vĂĄrias (falhou dia 10, entregou dia 12). É a base da anĂĄlise + # operacional — quantas idas ao local foram necessĂĄrias. + def visitas + @visitas ||= begin base = @filtros.empty? ? registros : registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } } @data ? base.select { |r| data_de(r['checkout']) == @data } : base end end + # NOTAS FISCAIS: uma linha por NF, com a ÚLTIMA visita dela. É a base de + # TODOS os KPIs desta tela, porque Ă© o que confere com os documentos fĂ­sicos + # e com a aba ENTREGAS da planilha entregue ao cliente (Analytics:: + # PlanilhaEntregas tambĂ©m usa "Ășltimo status por reference_id"). + # + # ⚠ O dedup precisa acontecer AQUI — depois do perĂ­odo e do cross-filter — e + # nĂŁo no SQL. Enquanto ele rodava como ROW_NUMBER + `rn = 1` sobre a tabela + # INTEIRA, uma NF reentregue DEPOIS do fim do perĂ­odo perdia a visita que + # estava DENTRO dele e sumia da contagem do mĂȘs. + def linhas + @linhas ||= por_nota.values.map { |vs| vs.max_by { |r| ordem_visita(r) } } + end + # Campos pesquisĂĄveis da tabela espelho (planilha da operação). BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name address nome_completo endereco_completo status_gade operacao].freeze @@ -103,6 +126,34 @@ module Analytics total - sucesso - recusas end + # ── Camada operacional (por VISITA, nĂŁo por NF) ─────────────── + # Os KPIs acima contam NOTAS porque Ă© o que o cliente paga e confere. Estes + # contam IDAS AO LOCAL — Ă© o que o motorista recebe e o que o dashboard + # financeiro usa (Entrega.contar_atendidas conta linhas). Sem eles, uma NF + # que falhou duas vezes antes de ser entregue aparecia como 100% de sucesso + # e o insucesso sumia da tela. + def total_visitas + visitas.size + end + + # Idas ao local alĂ©m da primeira de cada NF. + def retentativas + total_visitas - total + end + + def visitas_insucesso + visitas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) } + end + + # NFs que hoje constam como ENTREGUES mas custaram mais de uma ida — a + # informação que o dedup escondia por completo. + def notas_reentregues + @notas_reentregues ||= por_nota.count do |_nf, vs| + vs.any? { |r| Entrega::STATUS_FALHA.include?(r['status']) } && + vs.max_by { |r| ordem_visita(r) }['status'] == 'completed' + end + end + # Donut "Insucessos %": completas vs falhas (sobre o total). def insucessos_pct { @@ -206,6 +257,27 @@ module Analytics private + # NF => visitas dela (jĂĄ filtradas). Chave string: reference_id Ă© numĂ©rico no + # espelho e texto na tabela da operação. + def por_nota + @por_nota ||= visitas.group_by { |r| r['reference_id'].to_s } + end + + # Ordem de "Ășltima visita": quem tem checkout ganha de quem nĂŁo tem e, entre + # as com checkout, vence a mais recente. Mesma regra do ROW_NUMBER que a + # planilha do cliente usa (checkout DESC NULLS LAST). + def ordem_visita(registro) + t = tempo_de(registro['checkout']) + [t ? 1 : 0, t || Time.at(0)] + end + + def tempo_de(valor) + return nil if valor.nil? || valor.to_s.strip.empty? + valor.to_time + rescue ArgumentError, TypeError, NoMethodError + nil + end + def datas_checkout @datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) } end @@ -247,35 +319,33 @@ module Analytics conn = ActiveRecord::Base.connection + rastreio = conn.quote_table_name(Entrega.table_name) + conta = Entrega.condicao_conta_sql('r') + unions = @tabelas.map do |tabela| gade = conn.quote_table_name(tabela) label = conn.quote(Operacao.label(tabela)) <<~SQL.strip SELECT #{label} AS operacao, + r.tracking_id, r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address, r.checkout, r.planned_date, r.foto_da_fachada, r.latitude, r.longitude, r.checkout_latitude, r.checkout_longitude, #{selects_gade(conn, tabela)} - FROM ultimo r + FROM #{rastreio} r INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal - WHERE r.rn = 1#{filtro_periodo(conn)} + WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)} SQL end - # Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*) - # jĂĄ restringe aos dados do cliente. MantĂ©m a query idĂȘntica Ă  de gestĂŁo. - sql = <<~SQL - WITH ultimo AS ( - SELECT *, - ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn - FROM #{conn.quote_table_name(Entrega.table_name)} - WHERE reference_id IS NOT NULL - ) - #{unions.join("\nUNION ALL\n")} - SQL - - conn.select_all(sql).to_a + # Traz TODAS as visitas; a redução para uma linha por NF Ă© feita em #linhas, + # jĂĄ depois do perĂ­odo e do cross-filter (ver o comentĂĄrio lĂĄ). + # + # `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de + # uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN + # devolveria a MESMA visita mais de uma vez e inflaria a contagem. + conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] } end # Colunas extras da tabela gade que sĂł existem em ALGUMAS operaçÔes (ex.: diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 5256fb3..83690cf 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -168,6 +168,11 @@ · <%= @entregas_falhadas %> falhadas + <%# Conta VISITAS (Ă© por ida ao local que o motorista recebe). O dashboard + de OperaçÔes conta NOTAS — a diferença sĂŁo retentativas da mesma NF. %> +

+ visitas atendidas · <%= @notas_atendidas %> notas fiscais +

<%# Card 3: ConsolidaçÔes %> diff --git a/app/views/operacoes_dashboard/_fora_operacao.html.erb b/app/views/operacoes_dashboard/_fora_operacao.html.erb new file mode 100644 index 0000000..b9ca8c5 --- /dev/null +++ b/app/views/operacoes_dashboard/_fora_operacao.html.erb @@ -0,0 +1,81 @@ +<%# locals: fora (Analytics::NotasForaOperacao), inicio, fim (Date) + NFs entregues no perĂ­odo que nĂŁo constam em nenhuma planilha de operação — + os planos avulsos / de inclusĂŁo. Contam no dashboard financeiro e antes + sumiam daqui (o INNER JOIN com a tabela da operação as descartava). %> +
+
+
+

+ <%= icone :parcial, cor: 'text-amber-400', espaco: false %> + Notas fora da operação +

+

+ Entregues em <%= inicio.strftime('%d/%m/%Y') %> – <%= fim.strftime('%d/%m/%Y') %> + sem constar em nenhuma planilha gade_entregas_* — planos avulsos e de inclusão. +

+
+
+ NFs <%= fora.total %> + entregues <%= fora.entregues %> + recusas <%= fora.nao_entregues %> + <% if fora.pendentes.positive? %> + pendentes <%= fora.pendentes %> + <% end %> +
+
+ + <% if fora.total.zero? %> +

+ Nenhuma nota fora das planilhas de operação neste período. +

+ <% else %> + <%# Quebra por plano de origem — Ă© aqui que "(Avulsa)" e "INCLUSÃO" se separam. %> +
+ <% fora.por_plano.first(8).each do |grupo| %> + + <%= grupo[:nome] %> <%= grupo[:total] %> + + <% end %> +
+ +
+ + + + + + + + + + + + + + <% fora.listagem.each do |linha| %> + <% sucesso = linha['status'] == 'completed' %> + + + + + + + + + + <% end %> + +
NFPlano / tĂ­tuloMotoristaUnidadeDataResultadoMotivo
<%= linha['reference_id'] %><%= fora.plano(linha) %><%= linha['driver'].presence || '—' %><%= linha['contact_name'].presence || '—' %> + <%= (linha['checkout'].presence || linha['planned_date'].presence)&.to_date&.strftime('%d/%m/%Y') || '—' %> + + <%= sucesso ? 'Entregue' : (linha['status'].presence || '—') %> + <%= linha['observation'].presence || '—' %>
+
+ + <% if fora.truncada? %> +

+ Mostrando as <%= Analytics::NotasForaOperacao::LIMITE %> mais recentes de <%= fora.total %>. +

+ <% end %> + <% end %> +
diff --git a/app/views/operacoes_dashboard/_painel.html.erb b/app/views/operacoes_dashboard/_painel.html.erb index 417794f..f32f58d 100644 --- a/app/views/operacoes_dashboard/_painel.html.erb +++ b/app/views/operacoes_dashboard/_painel.html.erb @@ -24,6 +24,7 @@

Total de Entregas

<%= metricas.total %>

+

notas fiscais da operação

@@ -49,6 +50,37 @@ <%= pct_total.(metricas.pendentes) %>% do total

+ + <%# Camada operacional: os cards acima contam NOTAS (o que o cliente paga e + o que confere nos documentos). Aqui ficam as IDAS ao local — que Ă© o + que o motorista recebe e o que o dashboard financeiro conta. Sem esta + faixa, uma NF que sĂł foi entregue na segunda tentativa aparecia como + sucesso puro e o insucesso sumia da tela. %> +
+

Visitas ao local

+
+
+
Visitas realizadas
+
<%= metricas.total_visitas %>
+
+
+
Retentativas
+
<%= metricas.retentativas %>
+
+
+
Insucessos (por visita)
+
<%= metricas.visitas_insucesso %>
+
+
+
Entregues na 2ÂȘ ida ou mais
+
<%= metricas.notas_reentregues %>
+
+
+

+ O dashboard financeiro conta VISITAS (o motorista recebe por ida); esta tela conta NOTAS. + A diferença são as <%= metricas.retentativas %> retentativas acima. +

+
<%# Coluna direita — grĂĄficos/tabelas ocupam o espaço ao lado dos cards. %> diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index 8c70d20..d3e8abc 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -57,6 +57,7 @@ <%= icone :comparar %> Comparação entre operaçÔes (cada uma no seu prĂłprio perĂ­odo) <% elsif @metricas&.data_inicio %> <%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> – <%= @metricas.data_fim.strftime('%d/%m/%Y') %> + <%= @periodo_explicito ? '(perĂ­odo filtrado)' : '(operação inteira)' %> <% elsif @operacao %> <%= Operacao.label(@operacao) %> <% end %> @@ -64,9 +65,11 @@

- <%# Filtro de perĂ­odo — sĂł na visĂŁo Global (a faixa de data nĂŁo se aplica a - operação Ășnica, que usa o perĂ­odo natural dos prĂłprios dados). %> - <% if @modo == 'global' %> + <%# Filtro de perĂ­odo — Global e Operação. No modo Operação ele sĂł entra em + vigor quando o operador ESCOLHE uma faixa (params inicio/fim): sem isso o + recorte Ă© a operação inteira, senĂŁo abrir uma operação de meses atrĂĄs + cairia no mĂȘs corrente e mostraria zero. %> + <% if %w[global operacao].include?(@modo) %>
<%= icone :calendario, espaco: false %> @@ -77,12 +80,22 @@
<% atalhos.each do |label, (ini, fim)| %> - <% ativo = @periodo_inicio == ini && @periodo_fim == fim %> + <%# No Global a faixa SEMPRE vale (Ă© o que define a visĂŁo); no modo + Operação sĂł quando escolhida — senĂŁo o atalho apareceria aceso + sem estar sendo aplicado. %> + <% ativo = (@periodo_explicito || @modo == 'global') && @periodo_inicio == ini && @periodo_fim == fim %> <%= link_to label, - operacoes_dashboard_path(modo: 'global', inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d')), + operacoes_dashboard_path(ctx_ops.merge(modo: @modo, inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d'))), data: { turbo: false }, class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> <% end %> + <%# Volta ao recorte natural da operação (sem faixa de datas na URL). %> + <% if @modo == 'operacao' %> + <%= link_to 'Operação inteira', + operacoes_dashboard_path(ctx_ops.merge(modo: 'operacao')), + data: { turbo: false }, + class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{@periodo_explicito ? 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500' : 'bg-orange-500 text-black border-orange-500 font-bold'}" %> + <% end %>
<% end %> @@ -90,12 +103,16 @@ <%# ── Abas de modo + atalho para a planilha ─────────────── %> <% modos = { 'operacao' => [:operacao, 'Operação'], 'global' => [:global, 'Global'], 'comparar' => [:comparar, 'Comparar'] } %> + <%# A faixa de datas sĂł viaja entre as abas se o operador tiver ESCOLHIDO uma. + Carregar o default (mĂȘs corrente) ao entrar em "Operação" faria uma operação + de meses atrĂĄs abrir zerada. %> + <% ctx_periodo = @periodo_explicito ? { inicio: ini_iso, fim: fim_iso } : {} %>
<% modos.each do |m, (ic, label)| %> <% ativo = @modo == m %> <%= link_to rotulo(ic, label, cor: (ativo ? nil : 'text-brand-laranja')), - operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), + operacoes_dashboard_path(ctx_ops.merge(ctx_periodo).merge(modo: m)), data: { turbo: false }, class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %> <% end %> @@ -157,6 +174,9 @@ <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> <% end %> + <% if @fora_operacao %> + <%= render 'fora_operacao', fora: @fora_operacao, inicio: @periodo_inicio, fim: @periodo_fim %> + <% end %> <% elsif @modo == 'global' %>
@@ -167,6 +187,9 @@ <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> <% end %> + <% if @fora_operacao %> + <%= render 'fora_operacao', fora: @fora_operacao, inicio: @periodo_inicio, fim: @periodo_fim %> + <% end %> <% else # comparar %>
diff --git a/spec/services/analytics/notas_fora_operacao_spec.rb b/spec/services/analytics/notas_fora_operacao_spec.rb new file mode 100644 index 0000000..6a422fb --- /dev/null +++ b/spec/services/analytics/notas_fora_operacao_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe Analytics::NotasForaOperacao do + subject(:fora) { described_class.new(inicio: Date.new(2026, 8, 1), fim: Date.new(2026, 8, 31)) } + + # Visitas jĂĄ filtradas pelo SQL (NFs que nĂŁo estĂŁo em nenhuma gade_entregas_*). + let(:visitas) do + [ + row(nf: 100, status: 'failed', driver: 'Carlos', checkout: '2026-08-10 09:00:00', title: 'NF 100 - FULANO (Avulsa)'), + row(nf: 100, status: 'completed', driver: 'Carlos', checkout: '2026-08-12 09:00:00', title: 'NF 100 - FULANO (Avulsa)'), + row(nf: 101, status: 'completed', driver: 'Pedro', checkout: '2026-08-11 09:00:00', title: 'INCLUSÃO'), + row(nf: 102, status: 'failed', driver: 'Pedro', checkout: '2026-08-13 09:00:00', title: 'INCLUSÃO', obs: 'ÓBITO'), + row(nf: 103, status: 'pending', driver: 'Marcos', planned: '2026-08-20', title: nil) + ] + end + + before { allow(fora).to receive(:visitas).and_return(visitas) } + + it 'conta uma linha por NF, pelo status da Ășltima visita' do + expect(fora.total).to eq(4) + expect(fora.entregues).to eq(2) + expect(fora.nao_entregues).to eq(1) + expect(fora.pendentes).to eq(1) + end + + it 'agrupa por plano de origem (Avulsa x INCLUSÃO)' do + expect(fora.por_plano).to contain_exactly( + { nome: 'INCLUSÃO', total: 2 }, + { nome: 'NF 100 - FULANO (Avulsa)', total: 1 }, + { nome: 'SEM PLANO IDENTIFICADO', total: 1 } + ) + end + + it 'ordena a listagem da visita mais recente para a mais antiga' do + expect(fora.listagem.map { |r| r['reference_id'] }).to eq([102, 100, 101, 103]) + end + + # NOT IN com NULL na subquery devolve zero linhas — por isso cada SELECT da + # uniĂŁo precisa filtrar nota_fiscal IS NOT NULL. + it 'protege o NOT IN contra nota_fiscal NULL' do + allow(Operacao).to receive(:nomes_validos).and_return(%w[gade_entregas_teste]) + sql = nil + allow(ActiveRecord::Base.connection).to receive(:select_all) { |consulta| sql = consulta; [] } + allow(ActiveRecord::Base.connection).to receive(:columns).and_return([]) + + described_class.new(inicio: Date.new(2026, 8, 1), fim: Date.new(2026, 8, 31)).send(:carregar) + + expect(sql).to include('nota_fiscal IS NOT NULL') + expect(sql).to include('NOT IN') + end + + context 'sem nenhuma nota fora da operação' do + let(:visitas) { [] } + + it 'nĂŁo quebra e informa vazio' do + expect(fora.total).to eq(0) + expect(fora.any?).to be(false) + expect(fora.por_plano).to eq([]) + end + end + + def row(nf:, status:, driver:, checkout: nil, planned: nil, title: nil, obs: nil) + { 'reference_id' => nf, 'status' => status, 'driver' => driver, + 'checkout' => checkout, 'planned_date' => planned, 'observation' => obs, + 'contact_name' => nil, 'title' => title, 'notes' => nil, 'comments' => nil, 'route_id' => nil } + end +end diff --git a/spec/services/analytics/operacao_metricas_spec.rb b/spec/services/analytics/operacao_metricas_spec.rb index ae1c4a7..3deb4cb 100644 --- a/spec/services/analytics/operacao_metricas_spec.rb +++ b/spec/services/analytics/operacao_metricas_spec.rb @@ -3,14 +3,16 @@ require 'rails_helper' RSpec.describe Analytics::OperacaoMetricas do # Linhas simuladas (mesma forma do retorno de select_all) — evita depender das # tabelas externas no teste. Stubamos #registros para focar nas agregaçÔes. + # Uma NF por linha: nesse conjunto nenhuma visita se repete, entĂŁo as duas + # camadas (visitas x notas) coincidem. let(:rows) do [ - row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'), - row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'), - row(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'), - row(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'), - row(status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'), - row(status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24') + row(nf: 1, status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'), + row(nf: 2, status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'), + row(nf: 3, status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'), + row(nf: 4, status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'), + row(nf: 5, status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'), + row(nf: 6, status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24') ] end @@ -31,6 +33,51 @@ RSpec.describe Analytics::OperacaoMetricas do expect(metricas.pendentes).to eq(1) end + it 'sem NF repetida, visitas e notas coincidem' do + expect(metricas.total_visitas).to eq(6) + expect(metricas.retentativas).to eq(0) + expect(metricas.notas_reentregues).to eq(0) + end + + # A regressĂŁo que motivou as duas camadas: a mesma NF visitada duas vezes + # (falhou, depois entregou) contava 2 no dashboard financeiro e 1 aqui, sem + # nada na tela explicando a diferença — e o insucesso desaparecia por completo. + context 'com NF visitada mais de uma vez' do + let(:rows) do + [ + row(nf: 10, status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'AUSENTE', checkout: '2026-06-10 09:00:00'), + row(nf: 10, status: 'completed', driver: 'Pedro', sts: 'STS PERUS', gade: 'NOVO', checkout: '2026-06-12 09:00:00'), + row(nf: 11, status: 'completed', driver: 'Pedro', sts: 'STS PERUS', gade: 'NOVO', checkout: '2026-06-12 10:00:00') + ] + end + + it 'conta a NF uma vez, pelo status da ÚLTIMA visita' do + expect(metricas.total).to eq(2) + expect(metricas.sucesso).to eq(2) + expect(metricas.recusas).to eq(0) + end + + it 'expĂ”e as idas ao local em vez de descartĂĄ-las em silĂȘncio' do + expect(metricas.total_visitas).to eq(3) + expect(metricas.retentativas).to eq(1) + expect(metricas.visitas_insucesso).to eq(1) + expect(metricas.notas_reentregues).to eq(1) + end + + it 'visita sem checkout perde para a que tem' do + sem_checkout = [ + row(nf: 20, status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24'), + row(nf: 20, status: 'completed', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', checkout: '2026-06-20 09:00:00') + ] + m = described_class.new(tabelas: []) + allow(m).to receive(:registros).and_return(sem_checkout) + + expect(m.total).to eq(1) + expect(m.sucesso).to eq(1) + expect(m.pendentes).to eq(0) + end + end + it 'calcula insucessos %' do ins = metricas.insucessos_pct expect(ins[:completed]).to eq(3) @@ -104,9 +151,9 @@ RSpec.describe Analytics::OperacaoMetricas do end end - def row(status:, driver:, sts:, gade:, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil) + def row(status:, driver:, sts:, gade:, nf: nil, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil) { - 'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade, + 'reference_id' => nf, 'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade, 'observation' => obs, 'checkout' => checkout, 'planned_date' => planned, 'latitude' => lat, 'longitude' => lng, 'checkout_latitude' => clat, 'checkout_longitude' => clng -- 2.49.1 From 94bbe7556f9d7f8021ed534560269d04d1a598c478446df73839cf7eddca119c Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 24 Aug 2026 16:26:27 -0300 Subject: [PATCH 02/40] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20itens=20que=20e?= =?UTF-8?q?st=C3=A3o=20fora=20do=20plano=20valores=20no=20dash=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++++- app/controllers/dashboard_controller.rb | 34 ++++++ app/models/operacao.rb | 25 ++++ app/services/analytics/notas_fora_operacao.rb | 75 +++++++++--- app/views/dashboard/index.html.erb | 10 +- app/views/dashboard/pendentes.html.erb | 113 ++++++++++++++++++ .../_fora_operacao.html.erb | 20 ++-- config/routes.rb | 2 + spec/requests/dashboard_spec.rb | 63 ++++++++++ .../analytics/notas_fora_operacao_spec.rb | 70 ++++++++--- 10 files changed, 403 insertions(+), 48 deletions(-) create mode 100644 app/views/dashboard/pendentes.html.erb diff --git a/README.md b/README.md index 36b65aa..075e16c 100644 --- a/README.md +++ b/README.md @@ -2176,6 +2176,26 @@ inteira — senĂŁo abrir uma operação de meses atrĂĄs cairia no mĂȘs corrente **Financeiro — linha "N notas fiscais"** no card Total Entregas, ao lado de "visitas atendidas". +**Financeiro — card "N pendentes" agora Ă© clicĂĄvel** → `/dashboard/pendentes`, a tela nova +**"Entregas em aberto"**. O nĂșmero existia desde sempre e **nenhuma tela listava as linhas por trĂĄs +dele** — sĂł dava para descobrir por `rails runner`. A lista traz NF, status, motorista, veĂ­culo, +unidade, data planejada, **em qual operação a NF estĂĄ** (ou "fora da operação") e **quantas visitas +o rastreio tem para ela**. Essas duas Ășltimas colunas respondem sozinhas por que a entrega ficou em +aberto e por que o dashboard de OperaçÔes nĂŁo a mostrava. + +### 🔍 O que o dado real mostrou (24/08/2026, teste) +A "1 pendente" que nĂŁo aparecia em OperaçÔes era a **NF 85382 — MARIA APARECIDA JESUS SANTOS**: +plano avulso, status `pending`, **sem motorista**, planejada 03/08/2026, STS VILA PRUDENTE _ +SAPOPEMBA. Nota **fora da operação** — o `INNER JOIN` a descartava. Confirmado no painel novo. + +⚠ **`title` NÃO Ă© o nome do plano.** No dado real ele traz `NF 89096 - KAIQUE TAUAN DA SILVA` — o +formato da coluna A da planilha de importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o +**destinatĂĄrio**. Agrupar por ele dava um grupo por NF. `COLUNAS_PLANO` passou a ser +`notes, comments, route_id`; `title` virou a coluna "DestinatĂĄrio". Quando nenhuma coluna de plano +vem preenchida, a quebra cai para **unidade**, que ainda informa algo. **A coluna que carrega +"(Avulsa)"/"INCLUSÃO" segue nĂŁo confirmada** — pode ser que o espelho simplesmente nĂŁo a traga +(o sync jĂĄ deixa 4 colunas 100% NULL). + ### ⚙ Pontos nĂŁo-Ăłbvios **Duas camadas no mesmo objeto.** `OperacaoMetricas#visitas` = uma linha por ida; `#linhas` = uma @@ -2198,16 +2218,20 @@ Tem spec para isso. ### 📂 Arquivos ``` app/models/entrega.rb (contas_gade + condicao_conta_sql) +app/models/operacao.rb (por_notas — em que operação cada NF estĂĄ) app/services/analytics/operacao_metricas.rb (visitas x linhas; dedup pĂłs-filtro; conta) app/services/analytics/notas_fora_operacao.rb (NOVO — avulsas/inclusĂŁo) app/controllers/operacoes_dashboard_controller.rb (perĂ­odo no modo Operação; @fora_operacao) -app/controllers/dashboard_controller.rb (@notas_atendidas) +app/controllers/dashboard_controller.rb (@notas_atendidas + action #pendentes) +app/views/dashboard/pendentes.html.erb (NOVO — tela "Entregas em aberto") +config/routes.rb (GET /dashboard/pendentes) app/views/operacoes_dashboard/_painel.html.erb (faixa "Visitas ao local") app/views/operacoes_dashboard/_fora_operacao.html.erb (NOVO — painel de avulsas) app/views/operacoes_dashboard/index.html.erb (seletor de perĂ­odo + render do painel) -app/views/dashboard/index.html.erb (linha "N notas fiscais") +app/views/dashboard/index.html.erb (linha "N notas fiscais" + card clicĂĄvel) spec/services/analytics/operacao_metricas_spec.rb (+ NF com retentativa) spec/services/analytics/notas_fora_operacao_spec.rb (NOVO) +spec/requests/dashboard_spec.rb (+ tela de entregas em aberto) ``` > **Sem migration e sem gem nova** — model, services, controllers, views e specs. @@ -2218,12 +2242,15 @@ spec/services/analytics/notas_fora_operacao_spec.rb (NOVO) docker compose exec app bundle exec rspec \ spec/services/analytics/operacao_metricas_spec.rb \ spec/services/analytics/notas_fora_operacao_spec.rb \ + spec/requests/dashboard_spec.rb \ spec/models/entrega_spec.rb -# 2. Conferir a coluna que carrega o nome do PLANO ("(Avulsa)", "INCLUSÃO"). -# NotasForaOperacao::COLUNAS_PLANO tenta title, notes, comments, route_id nessa ordem. -# Se o painel mostrar "SEM PLANO IDENTIFICADO", a coluna certa Ă© outra: -docker compose exec app bin/rails runner 'puts Entrega.column_names.sort' +# 2. Achar a coluna do PLANO ("(Avulsa)", "INCLUSÃO"). title JÁ foi descartado +# (Ă© o destinatĂĄrio). Dump de uma nota avulsa real para ver onde o plano estĂĄ: +docker compose exec app bin/rails runner ' + e = Entrega.por_nf(85382).first + e&.attributes&.reject { |_, v| v.blank? }&.each { |k, v| puts "#{k.ljust(28)} #{v}" } +' # 3. Com dado real, no mesmo perĂ­odo nas duas telas: # financeiro "N notas fiscais" == OperaçÔes Global "Total de Entregas" diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index eca9195..5a46284 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -50,6 +50,40 @@ class DashboardController < ApplicationController disposition: 'attachment' end + # GET /dashboard/pendentes?inicio=&fim=&operacoes[]= + # Quais sĂŁo as entregas EM ABERTO do perĂ­odo — o card do dashboard mostrava sĂł + # o nĂșmero e nĂŁo havia tela nenhuma listando as linhas por trĂĄs dele. + # + # Em aberto = nem concluĂ­da nem falhada (Entrega.pendentes), pela data + # PLANEJADA: Ă© o Ășnico recorte sem checkout, entĂŁo planned_date Ă© o eixo + # possĂ­vel. Mesmo recorte do card, para o total bater. + def pendentes + skip_authorization + return redirect_to(motorista_dashboard_path) if current_user.motorista? + + @periodo_inicio, @periodo_fim = periodo_selecionado + @operacoes_agrupadas = Operacao.agrupadas_por_mes + @operacao_filtro = Operacao.sanitizar(params[:operacoes]) + + escopo = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim) + escopo = escopo.da_operacoes(@operacao_filtro) if @operacao_filtro.present? + + @total_em_aberto = escopo.count + begin + @pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50) + rescue Pagy::OverflowError + @pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50, page: 1) + end + + # Contexto de cada NF listada: em que operação ela estĂĄ (se estiver) e + # quantas visitas o rastreio tem para ela. As duas respostas juntas dizem por + # que a entrega ficou em aberto — e por que o dashboard de OperaçÔes nĂŁo a + # mostra: ou Ă© nota fora da operação, ou tem outra visita que a substituiu. + nfs = @em_aberto.map { |e| e.reference_id.to_s }.reject(&:empty?) + @operacao_por_nf = Operacao.por_notas(nfs) + @visitas_por_nf = nfs.any? ? Entrega.where(reference_id: nfs).group(:reference_id).count : {} + end + private # Faixa de datas vinda do filtro de calendĂĄrio (params inicio/fim). diff --git a/app/models/operacao.rb b/app/models/operacao.rb index 3620036..ec96cce 100644 --- a/app/models/operacao.rb +++ b/app/models/operacao.rb @@ -78,6 +78,31 @@ class Operacao grupos.sort_by { |titulo, _| ordem[titulo] }.map { |titulo, itens| [titulo, itens] } end + # Em QUAIS operaçÔes cada NF aparece: { '85382' => ['UBS NORTE AGO 2026'] }. + # NFs que nĂŁo estiverem em nenhuma planilha simplesmente nĂŁo vĂȘm no Hash — Ă© + # assim que a tela de pendentes marca "fora da operação" (plano avulso/de + # inclusĂŁo) sem precisar de uma query por tabela. + # + # SEGURANÇA: as tabelas vĂȘm de #nomes_validos (catĂĄlogo) + quote_table_name; + # as NFs entram por conn.quote. Uma query sĂł, com UNION ALL. + def por_notas(notas) + lista = Array(notas).map(&:to_s).map(&:strip).reject(&:empty?).uniq + return {} if lista.empty? + + tabelas = nomes_validos + return {} if tabelas.empty? + + valores = lista.map { |n| conn.quote(n) }.join(', ') + sql = tabelas.map do |t| + "SELECT nota_fiscal, #{conn.quote(label(t))} AS operacao " \ + "FROM #{conn.quote_table_name(t)} WHERE nota_fiscal IN (#{valores})" + end.join(' UNION ALL ') + + conn.select_all(sql).to_a + .group_by { |r| r['nota_fiscal'].to_s } + .transform_values { |rows| rows.map { |r| r['operacao'] }.uniq.sort } + end + # Dados agregados das entregas das operaçÔes informadas, para prĂ©-preencher o # formulĂĄrio de nova consolidação. Une as NFs de todas as tabelas vĂĄlidas. def dados(tabelas) diff --git a/app/services/analytics/notas_fora_operacao.rb b/app/services/analytics/notas_fora_operacao.rb index ebbe1e9..306bc67 100644 --- a/app/services/analytics/notas_fora_operacao.rb +++ b/app/services/analytics/notas_fora_operacao.rb @@ -16,11 +16,19 @@ module Analytics # Teto da listagem na tela (os totais continuam contando tudo). LIMITE = 300 - # Colunas do espelho que podem carregar o nome do PLANO/rota de origem - # ("(Avulsa)", "INCLUSÃO"...). Nem toda base tem todas — as ausentes viram - # NULL, mesmo padrĂŁo de selects_gade em OperacaoMetricas. Whitelist fixa: - # nada aqui vem do usuĂĄrio. - COLUNAS_PLANO = %w[title notes comments route_id].freeze + # Colunas extras do espelho que a tela usa. Nem toda base tem todas — as + # ausentes viram NULL, mesmo padrĂŁo de selects_gade em OperacaoMetricas. + # Whitelist fixa: nada aqui vem do usuĂĄrio. + COLUNAS_EXTRA = %w[title notes comments route_id].freeze + + # Candidatas ao nome do PLANO de origem ("(Avulsa)", "INCLUSÃO"...), na ordem + # de preferĂȘncia. + # + # ⚠ `title` NÃO entra aqui: conferido em 24/08/2026 com dado real, ele traz + # "NF 89096 - KAIQUE TAUAN DA SILVA" — o formato da coluna A da planilha de + # importação (`NF {nota_fiscal} - {nome_completo}`), ou seja, o destinatĂĄrio. + # Agrupar por ele daria um grupo por NF, o que nĂŁo informa nada. + COLUNAS_PLANO = %w[notes comments route_id].freeze def initialize(inicio:, fim:) @inicio = inicio&.to_date @@ -61,21 +69,48 @@ module Analytics total.positive? end - # Agrupamento por plano de origem, quando a base tiver alguma das colunas de - # COLUNAS_PLANO preenchida. Serve para separar "(Avulsa)" de "INCLUSÃO". - def por_plano - linhas.group_by { |r| plano(r) } - .map { |nome, rows| { nome: nome, total: rows.size } } - .sort_by { |h| -h[:total] } - end - - # RĂłtulo do plano de uma linha: primeira coluna de COLUNAS_PLANO preenchida. + # Nome do plano de origem: primeira coluna de COLUNAS_PLANO preenchida. + # nil quando o espelho nĂŁo guarda essa informação. def plano(registro) COLUNAS_PLANO.each do |coluna| valor = registro[coluna].to_s.strip return valor if valor.present? end - 'SEM PLANO IDENTIFICADO' + nil + end + + # DestinatĂĄrio/tĂ­tulo da visita (coluna `title` do espelho). + def titulo(registro) + registro['title'].to_s.strip.presence + end + + def plano_identificado? + linhas.any? { |r| plano(r) } + end + + # Quebra do painel em grupos. Quando o espelho traz o plano, separa + # "(Avulsa)" de "INCLUSÃO"; quando nĂŁo traz, agrupar por tĂ­tulo daria um + # grupo por NF — entĂŁo cai para a unidade, que ainda diz algo Ăștil. + # Devolve [rĂłtulo do agrupamento, [{ nome:, total: }, ...]]. + def agrupamento + if plano_identificado? + ['Plano', agrupar { |r| plano(r) || 'SEM PLANO' }] + else + ['Unidade', agrupar { |r| r['contact_name'].to_s.strip.presence || 'SEM UNIDADE' }] + end + end + + # RĂłtulo legĂ­vel do resultado da Ășltima visita. + RESULTADOS = { 'completed' => 'Entregue', 'failed' => 'NĂŁo entregue', + 'pending' => 'Em aberto', 'in_progress' => 'Em rota' }.freeze + + def resultado(registro) + status = registro['status'].to_s + RESULTADOS[status] || status.presence || 'sem status' + end + + def sucesso?(registro) + registro['status'] == 'completed' end def listagem @@ -88,6 +123,12 @@ module Analytics private + def agrupar + linhas.group_by { |r| yield(r) } + .map { |nome, rows| { nome: nome, total: rows.size } } + .sort_by { |h| -h[:total] } + end + def ordem_visita(registro) t = registro['checkout'].presence&.to_time [t ? 1 : 0, t || Time.at(0)] @@ -124,11 +165,11 @@ module Analytics conn.select_all(sql).to_a end - # Colunas de plano que existirem de fato; as demais viram NULL com o mesmo + # Colunas extras que existirem de fato; as demais viram NULL com o mesmo # alias, para a leitura da linha nĂŁo precisar saber quais existem. def selects_plano(conn) existentes = conn.columns(Entrega.table_name).map(&:name) - COLUNAS_PLANO.map do |coluna| + COLUNAS_EXTRA.map do |coluna| existentes.include?(coluna) ? "r.#{coluna}" : "CAST(NULL AS text) AS #{coluna}" end.join(', ') end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 83690cf..6d3aa67 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -164,7 +164,15 @@
<%= @entregas_pagas %> entregues · - <%= @entregas_pendentes %> pendentes + <%# ClicĂĄvel: leva Ă  lista das entregas em aberto, no MESMO recorte do card + (perĂ­odo + filtro de operação). O nĂșmero existia sem nenhuma tela por trĂĄs. %> + <%= link_to "#{@entregas_pendentes} pendentes", + dashboard_pendentes_path(inicio: @periodo_inicio.strftime('%Y-%m-%d'), + fim: @periodo_fim.strftime('%Y-%m-%d'), + operacoes: @operacao_filtro.presence), + data: { turbo: false }, + title: 'Ver quais sĂŁo as entregas em aberto', + class: 'text-yellow-500 underline decoration-dotted underline-offset-4 hover:text-yellow-300' %> · <%= @entregas_falhadas %> falhadas
diff --git a/app/views/dashboard/pendentes.html.erb b/app/views/dashboard/pendentes.html.erb new file mode 100644 index 0000000..8badc51 --- /dev/null +++ b/app/views/dashboard/pendentes.html.erb @@ -0,0 +1,113 @@ +<%# app/views/dashboard/pendentes.html.erb + O que hĂĄ por trĂĄs do card "N pendentes": as entregas EM ABERTO do perĂ­odo — + nem concluĂ­das nem falhadas, pela data planejada. Antes o nĂșmero existia e + nĂŁo havia tela nenhuma listando as linhas. %> +<% ctx = { inicio: @periodo_inicio.strftime('%Y-%m-%d'), fim: @periodo_fim.strftime('%Y-%m-%d'), operacoes: @operacao_filtro.presence }.compact %> + +
+ <%# ── Cabeçalho ─────────────────────────────────────────── %> +
+
+

Entregas em aberto

+

+ Planejadas entre <%= @periodo_inicio.strftime('%d/%m/%Y') %> e <%= @periodo_fim.strftime('%d/%m/%Y') %> + sem fechamento — o motorista nĂŁo registrou nem sucesso nem insucesso. + <% if @operacao_filtro.present? %> + · <%= @operacao_filtro.map { |t| Operacao.label(t) }.join(', ') %> + <% end %> +

+
+ <%= link_to 'â€č Voltar ao dashboard', dashboard_path(ctx), data: { turbo: false }, + class: 'px-4 py-2.5 rounded-xl text-sm whitespace-nowrap bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %> +
+ +
+
+

+ <%= @total_em_aberto %> <%= 'entrega'.pluralize(@total_em_aberto) %> em aberto +

+

+ A coluna Operação diz se a NF estå em alguma planilha + gade_entregas_*; Visitas é quantas + vezes o rastreio tem essa NF. As duas juntas explicam por que ela ficou em aberto. +

+
+ + <% if @em_aberto.empty? %> +

+ Nenhuma entrega em aberto neste período. 🎉 +

+ <% else %> +
+ + + + + + + + + + + + + + + <% @em_aberto.each do |entrega| %> + <% nf = entrega.reference_id.to_s %> + <% operacao = @operacao_por_nf[nf] %> + <% visitas = @visitas_por_nf[entrega.reference_id].to_i %> + + + + + + + + + + + <% end %> + +
NFStatusMotoristaVeículoUnidadePlanejadaOperaçãoVisitas
<%= entrega.reference_id %> + + <%= entrega.status.presence || 'sem status' %> + + <%= entrega.driver.presence || '— sem motorista' %><%= entrega.vehicle.presence || '—' %><%= entrega.contact_name.presence || '—' %><%= entrega.planned_date&.strftime('%d/%m/%Y') || '—' %> + <% if operacao.present? %> + <%= operacao.join(', ') %> + <% else %> + fora da operação + <% end %> + + <%= visitas %> +
+
+ + <%# Navegação de påginas (mesmo padrão de consolidacao_entregas/revisar) %> + <% if @pagy.pages > 1 %> + <% pag = ->(p) { dashboard_pendentes_path(ctx.merge(page: p)) } %> + + <% end %> + <% end %> +
+
diff --git a/app/views/operacoes_dashboard/_fora_operacao.html.erb b/app/views/operacoes_dashboard/_fora_operacao.html.erb index b9ca8c5..b84329b 100644 --- a/app/views/operacoes_dashboard/_fora_operacao.html.erb +++ b/app/views/operacoes_dashboard/_fora_operacao.html.erb @@ -29,9 +29,12 @@ Nenhuma nota fora das planilhas de operação neste período.

<% else %> - <%# Quebra por plano de origem — Ă© aqui que "(Avulsa)" e "INCLUSÃO" se separam. %> -
- <% fora.por_plano.first(8).each do |grupo| %> + <%# Quebra por plano de origem — Ă© aqui que "(Avulsa)" e "INCLUSÃO" se separam. + Se o espelho nĂŁo guardar o plano, cai para unidade (ver #agrupamento). %> + <% rotulo_grupo, grupos = fora.agrupamento %> +
+ <%= rotulo_grupo %>: + <% grupos.first(8).each do |grupo| %> <%= grupo[:nome] %> <%= grupo[:total] %> @@ -43,7 +46,8 @@ NF - Plano / tĂ­tulo + DestinatĂĄrio + Plano Motorista Unidade Data @@ -53,17 +57,17 @@ <% fora.listagem.each do |linha| %> - <% sucesso = linha['status'] == 'completed' %> <%= linha['reference_id'] %> - <%= fora.plano(linha) %> - <%= linha['driver'].presence || '—' %> + <%= fora.titulo(linha) || '—' %> + <%= fora.plano(linha) || '—' %> + <%= linha['driver'].presence || '— sem motorista' %> <%= linha['contact_name'].presence || '—' %> <%= (linha['checkout'].presence || linha['planned_date'].presence)&.to_date&.strftime('%d/%m/%Y') || '—' %> - <%= sucesso ? 'Entregue' : (linha['status'].presence || '—') %> + <%= fora.resultado(linha) %> <%= linha['observation'].presence || '—' %> diff --git a/config/routes.rb b/config/routes.rb index 4cbb931..d271f38 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,8 @@ Rails.application.routes.draw do # Dashboard principal (admin/gerente/operador) get '/dashboard', to: 'dashboard#index', as: :dashboard get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro + # Entregas em aberto do perĂ­odo (o que hĂĄ por trĂĄs do card "N pendentes") + get '/dashboard/pendentes', to: 'dashboard#pendentes', as: :dashboard_pendentes # Dashboard de anĂĄlise de entregas por operação (UBS Norte, EMAD, ...) get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index a3bd074..64aa241 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -218,3 +218,66 @@ RSpec.describe 'Dashboard — aba Consolidado do ranking de motoristas', type: : expect(painel_consolidado).to include('Nenhuma consolidação finalizada neste perĂ­odo') end end + +# Tela "Entregas em aberto" — o que hĂĄ por trĂĄs do card "N pendentes". +# +# Em aberto = nem concluĂ­da nem falhada, pela data PLANEJADA (Ă© o Ășnico recorte +# sem checkout). Tem que usar o MESMO recorte do card, senĂŁo o nĂșmero do +# dashboard leva a uma lista que nĂŁo bate com ele. +RSpec.describe 'Dashboard — entregas em aberto', type: :request do + include_context 'espelho de rastreio' + + let(:inicio) { '2026-08-01' } + let(:fim) { '2026-08-14' } + + before { sign_in create(:gerente) } + + def abrir_pendentes + get dashboard_pendentes_path, params: { inicio: inicio, fim: fim } + expect(response).to have_http_status(:ok) + response.body + end + + it 'lista sĂł o que estĂĄ em aberto dentro do perĂ­odo' do + semear([ + # Em aberto no perĂ­odo — a Ășnica que deve aparecer. + { reference_id: 85_382, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }, + # Fechadas: nĂŁo sĂŁo "em aberto", mesmo planejadas no perĂ­odo. + { reference_id: 85_383, driver: 'CARLOS', status: 'completed', + planned_date: Time.zone.local(2026, 8, 4, 8, 0), checkout: Time.zone.local(2026, 8, 4, 10, 0) }, + { reference_id: 85_384, driver: 'CARLOS', status: 'failed', + planned_date: Time.zone.local(2026, 8, 5, 8, 0), checkout: Time.zone.local(2026, 8, 5, 10, 0) }, + # Em aberto, mas planejada FORA do perĂ­odo. + { reference_id: 85_385, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 9, 2, 8, 0), checkout: nil } + ]) + + corpo = abrir_pendentes + expect(corpo).to include('85382') + expect(corpo).not_to include('85383') + expect(corpo).not_to include('85384') + expect(corpo).not_to include('85385') + end + + # Sem nenhuma tabela gade_entregas_* no banco de teste, toda NF Ă© "fora da + # operação" — que Ă© exatamente o caso real da NF 85382 que motivou a tela. + it 'marca a NF que nĂŁo estĂĄ em nenhuma planilha de operação' do + semear([{ reference_id: 85_382, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }]) + + expect(abrir_pendentes).to include('fora da operação') + end + + it 'o card do dashboard aponta para esta tela com o mesmo perĂ­odo' do + create(:configuracao, chave: 'preco_entrega', valor: '10.0') + semear([{ reference_id: 85_382, driver: nil, status: 'pending', + planned_date: Time.zone.local(2026, 8, 3, 8, 0), checkout: nil }]) + + get dashboard_path, params: { inicio: inicio, fim: fim } + # O href sai HTML-escapado (&) e a ordem dos params varia — confere o + # caminho e o perĂ­odo separadamente. + expect(response.body).to include(dashboard_pendentes_path) + expect(response.body).to match(%r{/dashboard/pendentes\?[^"]*inicio=2026-08-01}) + end +end diff --git a/spec/services/analytics/notas_fora_operacao_spec.rb b/spec/services/analytics/notas_fora_operacao_spec.rb index 6a422fb..a53886c 100644 --- a/spec/services/analytics/notas_fora_operacao_spec.rb +++ b/spec/services/analytics/notas_fora_operacao_spec.rb @@ -6,11 +6,11 @@ RSpec.describe Analytics::NotasForaOperacao do # Visitas jĂĄ filtradas pelo SQL (NFs que nĂŁo estĂŁo em nenhuma gade_entregas_*). let(:visitas) do [ - row(nf: 100, status: 'failed', driver: 'Carlos', checkout: '2026-08-10 09:00:00', title: 'NF 100 - FULANO (Avulsa)'), - row(nf: 100, status: 'completed', driver: 'Carlos', checkout: '2026-08-12 09:00:00', title: 'NF 100 - FULANO (Avulsa)'), - row(nf: 101, status: 'completed', driver: 'Pedro', checkout: '2026-08-11 09:00:00', title: 'INCLUSÃO'), - row(nf: 102, status: 'failed', driver: 'Pedro', checkout: '2026-08-13 09:00:00', title: 'INCLUSÃO', obs: 'ÓBITO'), - row(nf: 103, status: 'pending', driver: 'Marcos', planned: '2026-08-20', title: nil) + row(nf: 100, status: 'failed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-10 09:00:00'), + row(nf: 100, status: 'completed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00'), + row(nf: 101, status: 'completed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-11 09:00:00'), + row(nf: 102, status: 'failed', driver: 'Pedro', sts: 'STS SAPOPEMBA', checkout: '2026-08-13 09:00:00', obs: 'ÓBITO'), + row(nf: 103, status: 'pending', driver: nil, sts: 'STS SAPOPEMBA', planned: '2026-08-20') ] end @@ -23,18 +23,55 @@ RSpec.describe Analytics::NotasForaOperacao do expect(fora.pendentes).to eq(1) end - it 'agrupa por plano de origem (Avulsa x INCLUSÃO)' do - expect(fora.por_plano).to contain_exactly( - { nome: 'INCLUSÃO', total: 2 }, - { nome: 'NF 100 - FULANO (Avulsa)', total: 1 }, - { nome: 'SEM PLANO IDENTIFICADO', total: 1 } - ) - end - it 'ordena a listagem da visita mais recente para a mais antiga' do expect(fora.listagem.map { |r| r['reference_id'] }).to eq([102, 100, 101, 103]) end + it 'traduz o resultado da Ășltima visita' do + linha = fora.linhas.find { |r| r['reference_id'] == 103 } + expect(fora.resultado(linha)).to eq('Em aberto') + expect(fora.sucesso?(linha)).to be(false) + end + + # RegressĂŁo do dado real (24/08/2026): `title` traz "NF 89096 - KAIQUE TAUAN DA + # SILVA" — o destinatĂĄrio, nĂŁo o plano. Agrupar por ele daria um grupo por NF. + context 'quando o espelho sĂł tem `title` (destinatĂĄrio)' do + let(:visitas) do + [ + row(nf: 100, status: 'completed', driver: 'Thiago', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00', title: 'NF 100 - KAIQUE TAUAN DA SILVA'), + row(nf: 101, status: 'pending', driver: nil, sts: 'STS PENHA', planned: '2026-08-03', title: 'NF 101 - MARIA APARECIDA') + ] + end + + it 'nĂŁo trata o tĂ­tulo como plano' do + expect(fora.plano(fora.linhas.first)).to be_nil + expect(fora.titulo(fora.linhas.first)).to start_with('NF ') + expect(fora.plano_identificado?).to be(false) + end + + it 'cai para o agrupamento por unidade' do + rotulo, grupos = fora.agrupamento + expect(rotulo).to eq('Unidade') + expect(grupos).to eq([{ nome: 'STS PENHA', total: 2 }]) + end + end + + context 'quando o espelho traz o plano' do + let(:visitas) do + [ + row(nf: 100, status: 'completed', driver: 'Carlos', sts: 'STS PENHA', checkout: '2026-08-12 09:00:00', notes: '(Avulsa)'), + row(nf: 101, status: 'completed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-11 09:00:00', notes: 'INCLUSÃO'), + row(nf: 102, status: 'failed', driver: 'Pedro', sts: 'STS PENHA', checkout: '2026-08-13 09:00:00', notes: 'INCLUSÃO') + ] + end + + it 'separa (Avulsa) de INCLUSÃO' do + rotulo, grupos = fora.agrupamento + expect(rotulo).to eq('Plano') + expect(grupos).to eq([{ nome: 'INCLUSÃO', total: 2 }, { nome: '(Avulsa)', total: 1 }]) + end + end + # NOT IN com NULL na subquery devolve zero linhas — por isso cada SELECT da # uniĂŁo precisa filtrar nota_fiscal IS NOT NULL. it 'protege o NOT IN contra nota_fiscal NULL' do @@ -55,13 +92,14 @@ RSpec.describe Analytics::NotasForaOperacao do it 'nĂŁo quebra e informa vazio' do expect(fora.total).to eq(0) expect(fora.any?).to be(false) - expect(fora.por_plano).to eq([]) + expect(fora.agrupamento.last).to eq([]) end end - def row(nf:, status:, driver:, checkout: nil, planned: nil, title: nil, obs: nil) + def row(nf:, status:, driver:, sts: nil, checkout: nil, planned: nil, title: nil, notes: nil, obs: nil) { 'reference_id' => nf, 'status' => status, 'driver' => driver, 'checkout' => checkout, 'planned_date' => planned, 'observation' => obs, - 'contact_name' => nil, 'title' => title, 'notes' => nil, 'comments' => nil, 'route_id' => nil } + 'contact_name' => sts, 'title' => title, 'notes' => notes, + 'comments' => nil, 'route_id' => nil } end end -- 2.49.1 From 88f19dd8f5b5582da2bb391bacd07e97b290030c1990e9859f53cc37b36540a4 Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 24 Aug 2026 17:32:16 -0300 Subject: [PATCH 03/40] =?UTF-8?q?Implanta=C3=A7=C3=A3o=20da=20gera=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20mensagem=20de=20forma=20livre=20e=20mudan=C3=A7a?= =?UTF-8?q?=20na=20engine=20de=20mensagem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + README.md | 330 ++++++++++++++++++ app/controllers/admin/contatos_controller.rb | 77 ++++ .../admin/edicao_lancamentos_controller.rb | 21 ++ .../admin/eventos_notificacao_controller.rb | 140 ++++++++ .../admin/grupos_contato_controller.rb | 74 ++++ .../admin/mensagem_templates_controller.rb | 75 ++++ .../admin/notificacao_envios_controller.rb | 23 ++ .../admin/whatsapp_sessoes_controller.rb | 80 +++++ app/jobs/detectar_mudancas_operacao_job.rb | 64 ++++ app/jobs/disparar_eventos_agendados_job.rb | 48 +++ app/jobs/notificacao_job.rb | 26 ++ app/mailers/notificacao_mailer.rb | 18 + app/models/configuracao_notificacao.rb | 61 +++- app/models/consolidacao.rb | 7 + app/models/contato.rb | 68 ++++ app/models/evento_notificacao.rb | 119 +++++++ app/models/grupo_contato.rb | 38 ++ app/models/grupo_evento_assinatura.rb | 22 ++ app/models/mensagem_template.rb | 85 +++++ app/models/notificacao_envio.rb | 41 +++ app/models/operacao_snapshot.rb | 41 +++ app/policies/contato_policy.rb | 20 ++ app/policies/evento_notificacao_policy.rb | 23 ++ app/policies/grupo_contato_policy.rb | 3 + app/policies/notificacao_envio_policy.rb | 12 + app/policies/whatsapp_sessao_policy.rb | 13 + app/services/analytics/resumo_operacao.rb | 50 +++ app/services/notificacao/blocos.rb | 65 ++++ app/services/notificacao/cliente_whatsapp.rb | 98 ++++++ app/services/notificacao/despachante.rb | 155 ++++++++ app/services/notificacao/gatilhos.rb | 133 +++++++ app/services/notificacao/renderizador.rb | 159 +++++++++ app/services/notificacao/variaveis.rb | 78 +++++ app/services/notificacao/whatsapp.rb | 41 +++ app/services/notificacao_service.rb | 125 +++++-- app/views/admin/contatos/_form.html.erb | 52 +++ app/views/admin/contatos/edit.html.erb | 4 + app/views/admin/contatos/index.html.erb | 74 ++++ app/views/admin/contatos/new.html.erb | 4 + .../admin/eventos_notificacao/_form.html.erb | 102 ++++++ .../admin/eventos_notificacao/edit.html.erb | 4 + .../admin/eventos_notificacao/index.html.erb | 90 +++++ .../admin/eventos_notificacao/new.html.erb | 4 + app/views/admin/grupos_contato/_form.html.erb | 36 ++ app/views/admin/grupos_contato/edit.html.erb | 4 + app/views/admin/grupos_contato/index.html.erb | 65 ++++ app/views/admin/grupos_contato/new.html.erb | 4 + .../admin/mensagem_templates/edit.html.erb | 314 +++++++++++++++++ .../admin/notificacao_envios/index.html.erb | 95 +++++ .../admin/whatsapp_sessoes/show.html.erb | 114 ++++++ app/views/layouts/_navbar.html.erb | 14 + .../notificacao_mailer/mensagem.html.erb | 12 + config/routes.rb | 32 ++ config/schedule.rb | 19 + ...60824000001_create_notificacao_contatos.rb | 41 +++ ...260824000002_create_notificacao_eventos.rb | 70 ++++ ...0260824000003_create_notificacao_envios.rb | 31 ++ ...dd_baileys_to_configuracao_notificacoes.rb | 30 ++ ...0260824000005_create_mensagem_templates.rb | 29 ++ ...0260824000006_create_operacao_snapshots.rb | 27 ++ ...0260824000007_semear_eventos_de_gatilho.rb | 35 ++ docker-compose.yml | 26 ++ lib/tasks/notificacao.rake | 11 + spec/factories.rb | 28 ++ .../detectar_mudancas_operacao_job_spec.rb | 59 ++++ .../disparar_eventos_agendados_job_spec.rb | 72 ++++ spec/models/contato_spec.rb | 43 +++ spec/models/evento_notificacao_spec.rb | 69 ++++ spec/models/mensagem_template_spec.rb | 67 ++++ spec/services/notificacao/despachante_spec.rb | 134 +++++++ spec/services/notificacao/gatilhos_spec.rb | 69 ++++ .../services/notificacao/renderizador_spec.rb | 73 ++++ spec/services/notificacao/whatsapp_spec.rb | 35 ++ whatsapp/Dockerfile | 22 ++ whatsapp/package.json | 15 + whatsapp/server.js | 205 +++++++++++ 77 files changed, 4650 insertions(+), 25 deletions(-) create mode 100644 app/controllers/admin/contatos_controller.rb create mode 100644 app/controllers/admin/eventos_notificacao_controller.rb create mode 100644 app/controllers/admin/grupos_contato_controller.rb create mode 100644 app/controllers/admin/mensagem_templates_controller.rb create mode 100644 app/controllers/admin/notificacao_envios_controller.rb create mode 100644 app/controllers/admin/whatsapp_sessoes_controller.rb create mode 100644 app/jobs/detectar_mudancas_operacao_job.rb create mode 100644 app/jobs/disparar_eventos_agendados_job.rb create mode 100644 app/jobs/notificacao_job.rb create mode 100644 app/mailers/notificacao_mailer.rb create mode 100644 app/models/contato.rb create mode 100644 app/models/evento_notificacao.rb create mode 100644 app/models/grupo_contato.rb create mode 100644 app/models/grupo_evento_assinatura.rb create mode 100644 app/models/mensagem_template.rb create mode 100644 app/models/notificacao_envio.rb create mode 100644 app/models/operacao_snapshot.rb create mode 100644 app/policies/contato_policy.rb create mode 100644 app/policies/evento_notificacao_policy.rb create mode 100644 app/policies/grupo_contato_policy.rb create mode 100644 app/policies/notificacao_envio_policy.rb create mode 100644 app/policies/whatsapp_sessao_policy.rb create mode 100644 app/services/analytics/resumo_operacao.rb create mode 100644 app/services/notificacao/blocos.rb create mode 100644 app/services/notificacao/cliente_whatsapp.rb create mode 100644 app/services/notificacao/despachante.rb create mode 100644 app/services/notificacao/gatilhos.rb create mode 100644 app/services/notificacao/renderizador.rb create mode 100644 app/services/notificacao/variaveis.rb create mode 100644 app/services/notificacao/whatsapp.rb create mode 100644 app/views/admin/contatos/_form.html.erb create mode 100644 app/views/admin/contatos/edit.html.erb create mode 100644 app/views/admin/contatos/index.html.erb create mode 100644 app/views/admin/contatos/new.html.erb create mode 100644 app/views/admin/eventos_notificacao/_form.html.erb create mode 100644 app/views/admin/eventos_notificacao/edit.html.erb create mode 100644 app/views/admin/eventos_notificacao/index.html.erb create mode 100644 app/views/admin/eventos_notificacao/new.html.erb create mode 100644 app/views/admin/grupos_contato/_form.html.erb create mode 100644 app/views/admin/grupos_contato/edit.html.erb create mode 100644 app/views/admin/grupos_contato/index.html.erb create mode 100644 app/views/admin/grupos_contato/new.html.erb create mode 100644 app/views/admin/mensagem_templates/edit.html.erb create mode 100644 app/views/admin/notificacao_envios/index.html.erb create mode 100644 app/views/admin/whatsapp_sessoes/show.html.erb create mode 100644 app/views/notificacao_mailer/mensagem.html.erb create mode 100644 db/migrate/20260824000001_create_notificacao_contatos.rb create mode 100644 db/migrate/20260824000002_create_notificacao_eventos.rb create mode 100644 db/migrate/20260824000003_create_notificacao_envios.rb create mode 100644 db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb create mode 100644 db/migrate/20260824000005_create_mensagem_templates.rb create mode 100644 db/migrate/20260824000006_create_operacao_snapshots.rb create mode 100644 db/migrate/20260824000007_semear_eventos_de_gatilho.rb create mode 100644 lib/tasks/notificacao.rake create mode 100644 spec/jobs/detectar_mudancas_operacao_job_spec.rb create mode 100644 spec/jobs/disparar_eventos_agendados_job_spec.rb create mode 100644 spec/models/contato_spec.rb create mode 100644 spec/models/evento_notificacao_spec.rb create mode 100644 spec/models/mensagem_template_spec.rb create mode 100644 spec/services/notificacao/despachante_spec.rb create mode 100644 spec/services/notificacao/gatilhos_spec.rb create mode 100644 spec/services/notificacao/renderizador_spec.rb create mode 100644 spec/services/notificacao/whatsapp_spec.rb create mode 100644 whatsapp/Dockerfile create mode 100644 whatsapp/package.json create mode 100644 whatsapp/server.js diff --git a/.env.example b/.env.example index 709af88..c3b4c29 100644 --- a/.env.example +++ b/.env.example @@ -78,3 +78,11 @@ NOTIFICACAO_SECRET= # ── App ─────────────────────────────────────────────────────── APP_HOST=localhost:3000 APP_NAME=Reem LogĂ­stica + +# ── WhatsApp (Baileys — sessĂŁo pareada por QR code) ──────────────────────── +# Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string +# longa e aleatĂłria: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO. +WHATSAPP_TOKEN=troque_por_uma_string_aleatoria_longa +# URL da ponte dentro da rede do compose (nĂŁo mexa, a menos que troque o nome +# do serviço no docker-compose.yml). +BAILEYS_URL=http://whatsapp:3001 diff --git a/README.md b/README.md index 075e16c..cc6b91b 100644 --- a/README.md +++ b/README.md @@ -2258,3 +2258,333 @@ docker compose exec app bin/rails runner ' ``` + +--- + +
+📣 NotificaçÔes: WhatsApp por QR (Baileys) no lugar do Twilio + contatos, grupos e eventos — ETAPA 1 (24/08/2026) + +> ⚠ **STATUS: implementado, NADA executado.** NĂŁo hĂĄ Ruby/Bundler, Postgres nem Docker rodando na +> mĂĄquina de desenvolvimento. Foi conferida a sintaxe de todos os `.rb`/`.erb` (com um checker que +> emula o handler ERB do Rails, porque `<%= form_with 
 do %>` nĂŁo passa no ERB da stdlib) e do +> `server.js` (`node --check`). **Migration, `npm install`, build do container, pareamento do QR e a +> suĂ­te continuam pendentes** — roteiro no fim. + +> Esta Ă© a **etapa 1 de 3**. Ver "O que NÃO estĂĄ aqui" no fim. + +### 🎯 O problema +O canal de WhatsApp era Twilio (pago). AlĂ©m disso, "quem recebe" era implĂ­cito: o +`NotificacaoService` procurava o `User` motorista **pelo nome** da consolidação. Quem nĂŁo Ă© usuĂĄrio +do sistema — diretoria, cliente, terceiro — nĂŁo tinha como ser avisado, e nĂŁo havia tela nenhuma +para controlar isso. O corpo da mensagem era string interpolada em Ruby. + +### 🆕 O que existe agora + +| Tela | O quĂȘ | +|---|---| +| **NotificaçÔes → Contatos** | Cadastro manual: nome, WhatsApp e/ou e-mail, grupo. Quem tem sĂł nĂșmero recebe sĂł WhatsApp. | +| **NotificaçÔes → Grupos** | Diretoria, Operação, Motoristas
 É o **grupo** que assina os eventos. | +| **NotificaçÔes → Eventos** | Cria eventos e marca quais grupos recebem, por qual canal. Gatilho `manual` tem botĂŁo "disparar agora". | +| **NotificaçÔes → WhatsApp** | Pareamento por **QR code**, status ao vivo, envio de teste, desconectar. | +| **NotificaçÔes → Envios** | Log de tudo que saiu: destinatĂĄrio, canal, situação e o erro real. | + +### ⚙ Pontos nĂŁo-Ăłbvios + +**Container Node novo (`whatsapp/`).** NĂŁo existe biblioteca Ruby que fale o protocolo do WhatsApp +Web — Ă© Baileys. A ponte expĂ”e `/status`, `/enviar`, `/logout` e `/health`, protegida por +`WHATSAPP_TOKEN`. A **porta nĂŁo Ă© publicada** no compose: sĂł o container do Rails alcança. Publicar +exporia um endpoint que manda mensagem em nome da empresa. + +**A sessĂŁo precisa de volume.** `whatsapp_auth:/data` — sem ele, cada deploy exige escanear o QR +de novo. + +**Envio serializado e com intervalo.** Disparo em rajada Ă© o que mais causa banimento no canal nĂŁo +oficial. A fila do Node serializa e o Rails pausa entre mensagens (`whatsapp_intervalo_segundos`, +nasce em 5s). Como `sleep(5) × 30 contatos` penduraria o Puma por 2min30, o envio roda em +`NotificacaoJob`, **nunca dentro da requisição**. + +**`whatsapp_provedor` nasce em `twilio`.** O deploy nĂŁo muda o comportamento atĂ© o ADM parear o QR +e trocar o provedor na tela. `Notificacao::Whatsapp` Ă© o ponto Ășnico que escolhe — trocar +Twilio ↔ Baileys Ă© um campo, nĂŁo um `if` espalhado. + +**Nomes de rota ≠ nomes de controller, de propĂłsito.** `grupos_contato` e `eventos_notificacao` +tĂȘm singular igual ao plural para o Inflector (que nĂŁo fala portuguĂȘs) e o Rails sufixaria o helper +de index com `_index` — pegadinha silenciosa. As rotas se chamam `grupos`, `eventos`, `envios`. +Por isso o `form_with` dos grupos passa `url:` explĂ­cita: a rota polimĂłrfica de `GrupoContato` +procuraria `admin_grupo_contato_path`. + +**O aviso pessoal ao motorista nĂŁo regrediu.** Ele continua recebendo o e-mail formatado do +`ConsolidacaoMailer` (nĂŁo virou texto puro); o que mudou Ă© que o WhatsApp passa pelo provedor +escolhido e **tudo fica logado**. Os grupos recebem uma cĂłpia via `Despachante`, com +`envolvido: nil` para o motorista nĂŁo receber duas vezes. + +**Log em tabela, nĂŁo em arquivo.** O envio engole exceção de propĂłsito (um SMTP fora do ar nĂŁo pode +travar um fechamento). Com sessĂŁo QR — que cai sozinha e exige repareamento — "o motorista +recebeu?" vira pergunta de rotina, e a resposta precisava sair do `log/production.log`. + +### ⚠ O risco, dito na tela +Conectar por QR usa a porta do WhatsApp Web por engenharia reversa: estĂĄ **fora dos Termos do +WhatsApp** e a Meta **pode banir o nĂșmero** sem aviso. A tela de pareamento diz isso em texto e +recomenda **chip dedicado**, nĂŁo o nĂșmero principal da operação. + +### 📂 Arquivos +``` +db/migrate/20260824000001_create_notificacao_contatos.rb (NOVO) +db/migrate/20260824000002_create_notificacao_eventos.rb (NOVO — semeia os 2 eventos atuais) +db/migrate/20260824000003_create_notificacao_envios.rb (NOVO) +db/migrate/20260824000004_add_baileys_to_configuracao_...rb (NOVO) +whatsapp/{server.js,package.json,Dockerfile} (NOVO — ponte Baileys) +docker-compose.yml (serviço whatsapp + volume) +app/models/{grupo_contato,contato,evento_notificacao}.rb (NOVO) +app/models/{grupo_evento_assinatura,notificacao_envio}.rb (NOVO) +app/models/configuracao_notificacao.rb (provedor + credenciais Baileys) +app/services/notificacao/{cliente_whatsapp,whatsapp,despachante}.rb (NOVO) +app/services/notificacao_service.rb (grupos + provedor + log) +app/jobs/notificacao_job.rb (NOVO) +app/mailers/notificacao_mailer.rb + view (NOVO — e-mail genĂ©rico) +app/controllers/admin/{grupos_contato,contatos,eventos_notificacao}_controller.rb (NOVO) +app/controllers/admin/{whatsapp_sessoes,notificacao_envios}_controller.rb (NOVO) +app/policies/{contato,grupo_contato,evento_notificacao,notificacao_envio,whatsapp_sessao}_policy.rb (NOVO) +app/views/admin/{grupos_contato,contatos,eventos_notificacao,whatsapp_sessoes,notificacao_envios}/ (NOVO) +app/views/layouts/_navbar.html.erb (seção NotificaçÔes) +config/routes.rb + .env.example +spec/{models,services}/
 (NOVO) +``` + +> **4 migrations e 1 container novo.** Nenhuma gem nova no Gemfile. + +### ⏳ Pendente — roteiro +```bash +# 1. Gerar o token da ponte e colocar no .env do servidor: +openssl rand -hex 32 # -> WHATSAPP_TOKEN=... +# e BAILEYS_URL=http://whatsapp:3001 + +# 2. Subir (a 1ÂȘ vez baixa o Baileys; leva alguns minutos): +docker compose up -d --build + +# 3. Migrar: +docker compose exec app bin/rails db:migrate + +# 4. SuĂ­te (nĂŁo pĂŽde ser executada aqui — sem Ruby/Bundler local): +docker compose exec app bundle exec rspec \ + spec/models/contato_spec.rb spec/models/evento_notificacao_spec.rb \ + spec/services/notificacao/ + +# 5. Parear: NotificaçÔes → WhatsApp → ler o QR com o CHIP DEDICADO. +# Depois: ConfiguraçÔes → NotificaçÔes → provedor = Baileys, e enviar um teste. + +# 6. Cadastrar um grupo, um contato e marcar o grupo nos 2 eventos de sistema. +# Conferir o resultado em NotificaçÔes → Envios. +``` + +### 🚧 O que NÃO estĂĄ aqui (etapas 2 e 3) +- **Editor de blocos** (arrastar cabeçalho / tabela de valores / aviso / botĂŁo / rodapĂ©, com preview + e HTML montado no e-mail). Hoje o texto dos 2 eventos de sistema ainda Ă© o do cĂłdigo, e o disparo + manual usa um campo de texto. +- **Gatilhos novos**: `valor_alterado`, `operacao_alterada` e `agendado` jĂĄ existem como opção no + cadastro e o `Despachante` os atende — mas **ainda nĂŁo hĂĄ cĂłdigo chamando** esses gatilhos, nem o + job de varredura do agendado. Um evento com esses gatilhos hoje sĂł dispara pelo botĂŁo manual. + +
+ +--- + +
+đŸ§± Editor de blocos das mensagens — ETAPA 2 (24/08/2026) + +> ⚠ **STATUS: implementado, NADA executado.** Sem Ruby/Bundler/Postgres/Docker na mĂĄquina de +> desenvolvimento. Conferida a sintaxe de todos os `.rb`, de todos os `.erb` (checker que emula o +> handler do Rails) e **do JavaScript do editor** (`node --check` sobre o ` + diff --git a/app/views/admin/notificacao_envios/index.html.erb b/app/views/admin/notificacao_envios/index.html.erb new file mode 100644 index 0000000..710a37e --- /dev/null +++ b/app/views/admin/notificacao_envios/index.html.erb @@ -0,0 +1,95 @@ +<% ctx = { status: params[:status], canal: params[:canal] }.compact_blank %> +
+
+

Envios

+

+ O que saiu, para quem e se chegou. Uma falha de envio nunca trava o fechamento — + entĂŁo Ă© aqui que ela aparece. +

+
+ +
+ <% [['', 'Todos'], ['enviado', 'Enviados'], ['falhou', 'Falhados'], ['pendente', 'Pendentes']].each do |valor, rotulo| %> + <% ativo = params[:status].to_s == valor %> + <%= link_to rotulo, admin_envios_path(ctx.merge(status: valor.presence).compact), data: { turbo: false }, + class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> + <% end %> + | + <% [['', 'Todos os canais'], ['whatsapp', 'WhatsApp'], ['email', 'E-mail']].each do |valor, rotulo| %> + <% ativo = params[:canal].to_s == valor %> + <%= link_to rotulo, admin_envios_path(ctx.merge(canal: valor.presence).compact), data: { turbo: false }, + class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> + <% end %> + <% if @total_falhas.positive? %> + <%= @total_falhas %> falha(s) neste filtro + <% end %> +
+ +
+ <% if @envios.empty? %> +

Nenhum envio registrado ainda.

+ <% else %> +
+ + + + + + + + + + + + + <% @envios.each do |envio| %> + + + + + + + + + <% end %> + +
QuandoEventoDestinatårioCanalSituaçãoMensagem
<%= envio.created_at.strftime('%d/%m/%Y %H:%M') %><%= envio.evento_notificacao&.nome || '—' %> + <%= envio.contato&.nome || envio.user&.nome || '—' %> + <%= envio.destino %> + <%= envio.canal == 'whatsapp' ? 'WhatsApp' : 'E-mail' %> + <% cor = { 'enviado' => 'text-green-400', 'falhou' => 'text-red-400' }.fetch(envio.status, 'text-yellow-400') %> + <%= envio.status.capitalize %> + <% if envio.falhou? && envio.erro.present? %> + <%= envio.erro.truncate(160) %> + <% end %> + + <%= envio.corpo.to_s.truncate(90) %> +
+
+ + <% if @pagy.pages > 1 %> + <% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %> + + <% end %> + <% end %> +
+
diff --git a/app/views/admin/whatsapp_sessoes/show.html.erb b/app/views/admin/whatsapp_sessoes/show.html.erb new file mode 100644 index 0000000..bd56113 --- /dev/null +++ b/app/views/admin/whatsapp_sessoes/show.html.erb @@ -0,0 +1,114 @@ +
+
+

WhatsApp — conexão

+

+ Pareia o nĂșmero da empresa com este servidor lendo um QR code, como no WhatsApp Web. +

+
+ + <%# O aviso fica na TELA, nĂŁo sĂł no cĂłdigo: quem parear precisa saber o que + estĂĄ aceitando. %> +
+

⚠ Este canal nĂŁo Ă© oficial.

+

+ A conexĂŁo por QR usa a porta do WhatsApp Web por engenharia reversa. É gratuita e ilimitada, + mas estĂĄ fora dos Termos do WhatsApp — a Meta pode banir o nĂșmero sem aviso. + Use um chip dedicado, nunca o nĂșmero principal da operação. +

+

+ Disparo em rajada Ă© o que mais causa banimento. O intervalo entre mensagens fica em + <%= link_to 'ConfiguraçÔes → NotificaçÔes', admin_configuracao_notificacao_path, + data: { turbo: false }, class: 'underline hover:text-white' %> + (hoje: <%= @config.intervalo_envio %>s). +

+
+ +
+ <% if @estado['conectado'] %> +
+ +
+

Conectado

+

+ NĂșmero <%= @estado['numero'] %> + <% if @config.whatsapp_conectado_em %> · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %><% end %> +

+
+
+ +
+ <%= form_with url: testar_admin_whatsapp_sessao_path, method: :post, data: { turbo: false }, + class: 'flex flex-wrap items-end gap-3' do |f| %> +
+ <%= f.label :numero, 'Enviar teste para', class: 'block text-gray-400 text-sm mb-1' %> + <%= f.text_field :numero, placeholder: '(11) 92005-1157', + class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> +
+ <%= f.submit 'Enviar teste', + class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> + <% end %> + + <%= button_to 'Desconectar', desconectar_admin_whatsapp_sessao_path, method: :delete, + form: { data: { turbo_confirm: 'Isso encerra a sessĂŁo e para TODOS os envios de WhatsApp atĂ© vocĂȘ ler o QR de novo. Confirmar?' } }, + class: 'text-red-400 hover:text-red-300 text-sm' %> +
+ + <% elsif @estado['qr'].present? %> +
+

Leia o QR code

+

+ No celular do chip dedicado: WhatsApp → Aparelhos conectados → + Conectar um aparelho → aponte para o código abaixo. +

+ <%# O QR expira em ~20s e a ponte gera outro — o polling abaixo troca a + imagem sozinho, senão o operador ficaria lendo um código morto. %> + QR code do WhatsApp +

O cĂłdigo se renova sozinho a cada poucos segundos.

+
+ + <% else %> +
+ +
+

Desconectado

+

+ <%= @estado['erro'].presence || @estado['ultimo_erro'].presence || + 'A ponte ainda nĂŁo gerou um QR code. Aguarde alguns segundos e recarregue.' %> +

+ <% unless @cliente.configurado? %> +

+ Falta configurar a ponte: defina WHATSAPP_TOKEN no .env + (o mesmo valor do container whatsapp) e suba os containers. +

+ <% end %> +
+
+ <% end %> +
+
+ + diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 197d730..5e6e8f7 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -63,6 +63,20 @@ <%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %> <% end %>
+ + <%# NotificaçÔes: quem recebe (grupos/contatos) Ă© admin+gerente; ligar o + WhatsApp e criar eventos manda mensagem em nome da empresa — sĂł ADM. + Mesmo recorte das policies. %> +
+

NotificaçÔes

+ <%= nav_link_to 'Contatos', admin_contatos_path, icon: :usuarios %> + <%= nav_link_to 'Grupos', admin_grupos_path, icon: :tag %> + <%= nav_link_to 'Envios', admin_envios_path, icon: :historico %> + <% if current_user.admin? %> + <%= nav_link_to 'Eventos', admin_eventos_path, icon: :diario %> + <%= nav_link_to 'WhatsApp', admin_whatsapp_sessao_path, icon: :telefone %> + <% end %> +
<% end %> diff --git a/app/views/notificacao_mailer/mensagem.html.erb b/app/views/notificacao_mailer/mensagem.html.erb new file mode 100644 index 0000000..5c539ba --- /dev/null +++ b/app/views/notificacao_mailer/mensagem.html.erb @@ -0,0 +1,12 @@ +<%# Dois caminhos, e a diferença Ă© de segurança: + - @html: o corpo veio do Notificacao::Renderizador, que escapa todo texto do + editor e toda variĂĄvel. Sai como estĂĄ, senĂŁo as tags viriam visĂ­veis. + - senĂŁo: texto puro digitado — simple_format escapa e quebra em parĂĄgrafos. + Nunca inverter isso: `raw` em texto nĂŁo renderizado seria injeção de HTML. %> +<% if @html %> + <%= raw @corpo %> +<% else %> +
+ <%= simple_format(@corpo) %> +
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index d271f38..0d925e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,38 @@ Rails.application.routes.draw do resource :planilha_simpli_route, only: [:show], controller: 'planilhas_simpli_route' do get :baixar end + + # ── NotificaçÔes: quem recebe, o quĂȘ e por qual canal ────────────────── + # Grupos e contatos sĂŁo a lista de destinatĂĄrios cadastrada Ă  mĂŁo; eventos + # ligam um gatilho aos grupos; a sessĂŁo do WhatsApp Ă© o pareamento por QR. + # + # ⚠ Os nomes das rotas (`grupos`, `eventos`, `envios`) NÃO sĂŁo os nomes dos + # controllers de propĂłsito: `grupos_contato` e `eventos_notificacao` tĂȘm + # singular igual ao plural para o Inflector (que nĂŁo fala portuguĂȘs), e aĂ­ + # o Rails sufixa o helper de index com `_index` — pegadinha silenciosa. + resources :grupos, except: [:show], controller: 'grupos_contato' + resources :contatos, except: [:show] + resources :eventos, except: [:show], controller: 'eventos_notificacao' do + member do + post :disparar # gatilho `manual` — manda mensagem de verdade + + # Editor de blocos. O template Ă© identificado pelo CANAL, nĂŁo por id: + # existe no mĂĄximo um por evento+canal (Ă­ndice Ășnico), entĂŁo uma URL com + # id seria um identificador a mais para o mesmo par. + get 'template/:canal', to: 'mensagem_templates#edit', as: :template + patch 'template/:canal', to: 'mensagem_templates#update' + post 'template/:canal/preview', to: 'mensagem_templates#preview', as: :preview_template + end + end + resources :envios, only: [:index], controller: 'notificacao_envios' + + # Pareamento do WhatsApp por QR code (ponte Baileys). `show` faz polling do + # status via JSON enquanto o QR nĂŁo Ă© lido. + resource :whatsapp_sessao, only: [:show], controller: 'whatsapp_sessoes' do + get :status # JSON { conectado, numero, qr } + post :testar # envia uma mensagem de teste + delete :desconectar + end end # API interna — Dashboard mĂ©tricas diff --git a/config/schedule.rb b/config/schedule.rb index 67a603a..e143576 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -19,3 +19,22 @@ end every "0 1 * * *" do rake "simpli_route:series" end + +# De hora em hora — dispara os eventos de notificação com gatilho `agendado` +# que venceram (resumo diĂĄrio/semanal da operação). +# +# POR QUE varredura horĂĄria e nĂŁo uma linha por evento: hora e frequĂȘncia sĂŁo +# escolhidas na TELA e mudam a qualquer momento; reler o crontab a cada edição +# acoplaria a UI ao cron do sistema. `EventoNotificacao#vencido?` garante um +# disparo por dia (grava ultimo_disparo_em). Ver DispararEventosAgendadosJob. +every '0 * * * *' do + rake 'notificacao:agendados' +end + +# A cada 2h, das 07h Ă s 21h — compara os nĂșmeros de cada operação com o retrato +# anterior e avisa o que mudou (NF que entrou/saiu, entrega que trocou de +# status). A maioria dessas mudanças acontece no SimpliRoute e chega pelo sync +# do espelho, entĂŁo sĂł uma varredura as detecta. Ver DetectarMudancasOperacaoJob. +every '0 7-21/2 * * *' do + rake 'notificacao:mudancas_operacao' +end diff --git a/db/migrate/20260824000001_create_notificacao_contatos.rb b/db/migrate/20260824000001_create_notificacao_contatos.rb new file mode 100644 index 0000000..1ce37a5 --- /dev/null +++ b/db/migrate/20260824000001_create_notificacao_contatos.rb @@ -0,0 +1,41 @@ +# Lista de destinatĂĄrios das notificaçÔes, cadastrada Ă  mĂŁo pelo ADM. +# +# Antes, "quem recebe" era implĂ­cito: o NotificacaoService procurava o User +# motorista pelo NOME da consolidação. Quem nĂŁo Ă© usuĂĄrio do sistema (diretoria, +# cliente, terceiro) nĂŁo tinha como ser avisado, e nĂŁo havia tela nenhuma para +# controlar isso. +# +# O contato pertence a UM grupo, e Ă© o GRUPO que assina os eventos (ver +# 20260824000002) — assim cadastrar alguĂ©m novo Ă© escolher o grupo, nĂŁo repetir +# a configuração pessoa por pessoa. +class CreateNotificacaoContatos < ActiveRecord::Migration[7.1] + def change + create_table :grupos_contato do |t| + t.string :nome, null: false + t.string :descricao + t.boolean :ativo, null: false, default: true + t.timestamps + end + add_index :grupos_contato, 'LOWER(nome)', unique: true, name: 'index_grupos_contato_nome_unico' + + create_table :contatos do |t| + t.references :grupo_contato, foreign_key: true, index: true + + t.string :nome, null: false + # E.164 normalizado (+5511920051157). Vazio = contato sĂł de e-mail. + t.string :telefone + t.string :email + t.boolean :ativo, null: false, default: true + t.string :observacao + + # Quando o contato É um usuĂĄrio do sistema (motorista), guardar o vĂ­nculo + # permite o disparo "avisar o envolvido" achar a pessoa certa sem depender + # de casar nome por string, que Ă© o que se faz hoje. + t.references :user, foreign_key: true, index: true + + t.timestamps + end + add_index :contatos, :telefone + add_index :contatos, :email + end +end diff --git a/db/migrate/20260824000002_create_notificacao_eventos.rb b/db/migrate/20260824000002_create_notificacao_eventos.rb new file mode 100644 index 0000000..86d3e86 --- /dev/null +++ b/db/migrate/20260824000002_create_notificacao_eventos.rb @@ -0,0 +1,70 @@ +# Eventos de notificação + assinatura por grupo. +# +# O ADM cadastra quantos eventos quiser, mas o GATILHO sai de uma lista fixa: +# gatilho Ă© cĂłdigo. `manual` cobre o aviso pontual (botĂŁo "disparar agora") e +# `agendado` cobre o resumo periĂłdico; os demais sĂŁo pontos que o sistema jĂĄ +# conhece (consolidação finalizada, pagamento efetuado, valor alterado, dados da +# operação mudaram). +# +# `sistema: true` marca os eventos que nascem com o app: podem ser editados e +# desligados, mas nĂŁo apagados — apagar deixaria o cĂłdigo disparando no vazio. +class CreateNotificacaoEventos < ActiveRecord::Migration[7.1] + GATILHOS = %w[manual consolidacao_finalizada pagamento_efetuado + valor_alterado operacao_alterada agendado].freeze + + def up + create_table :eventos_notificacao do |t| + t.string :nome, null: false + t.string :chave, null: false # slug estĂĄvel usado pelo cĂłdigo + t.string :gatilho, null: false + t.string :descricao + t.boolean :ativo, null: false, default: true + t.boolean :sistema, null: false, default: false + + # Avisar tambĂ©m a pessoa diretamente envolvida no fato (o motorista do + # pagamento), alĂ©m dos grupos assinantes. É o comportamento de hoje. + t.boolean :notificar_envolvido, null: false, default: true + + # SĂł para gatilho `agendado`. + t.string :frequencia # diaria | semanal + t.integer :hora, default: 8 # 0..23 + t.integer :dia_semana # 0..6 (domingo=0), sĂł na semanal + t.datetime :ultimo_disparo_em + + t.timestamps + end + add_index :eventos_notificacao, :chave, unique: true + + create_table :grupo_evento_assinaturas do |t| + t.references :grupo_contato, null: false, foreign_key: true + t.references :evento_notificacao, null: false, foreign_key: true + t.string :canal, null: false, default: 'ambos' # ambos | whatsapp | email + t.boolean :ativo, null: false, default: true + t.timestamps + end + add_index :grupo_evento_assinaturas, %i[grupo_contato_id evento_notificacao_id], + unique: true, name: 'index_assinatura_grupo_evento_unico' + + # Os dois eventos que o cĂłdigo JÁ dispara hoje, para nada parar de funcionar + # no deploy. SQL cru de propĂłsito: migration nĂŁo deve depender de model. + agora = 'NOW()' + [ + ['Pagamento fechado', 'consolidacao_finalizada', 'consolidacao_finalizada', + 'Disparado quando uma consolidação Ă© finalizada.'], + ['Pagamento efetuado', 'pagamento_efetuado', 'pagamento_efetuado', + 'Disparado quando o pagamento de um motorista Ă© marcado como pago.'] + ].each do |nome, chave, gatilho, descricao| + execute(<<~SQL.squish) + INSERT INTO eventos_notificacao + (nome, chave, gatilho, descricao, ativo, sistema, notificar_envolvido, hora, created_at, updated_at) + VALUES (#{quote(nome)}, #{quote(chave)}, #{quote(gatilho)}, #{quote(descricao)}, + true, true, true, 8, #{agora}, #{agora}) + SQL + end + end + + def down + drop_table :grupo_evento_assinaturas + drop_table :eventos_notificacao + end +end diff --git a/db/migrate/20260824000003_create_notificacao_envios.rb b/db/migrate/20260824000003_create_notificacao_envios.rb new file mode 100644 index 0000000..77f27b4 --- /dev/null +++ b/db/migrate/20260824000003_create_notificacao_envios.rb @@ -0,0 +1,31 @@ +# Log de cada mensagem disparada — quem recebeu, por qual canal, com que texto e +# se deu certo. +# +# Hoje uma falha de envio sĂł vira linha no log do Rails (NotificacaoService +# engole a exceção de propĂłsito, para um SMTP fora do ar nĂŁo travar o +# fechamento). Isso Ă© certo, mas deixa o operador sem resposta para "o motorista +# recebeu?". Com o WhatsApp por sessĂŁo QR — que cai sozinho e precisa +# repareamento — essa pergunta passa a ser rotina. +class CreateNotificacaoEnvios < ActiveRecord::Migration[7.1] + def change + create_table :notificacao_envios do |t| + t.references :evento_notificacao, foreign_key: true, index: true + t.references :contato, foreign_key: true, index: true + t.references :user, foreign_key: true, index: true + + t.string :canal, null: false # whatsapp | email + t.string :destino, null: false # telefone E.164 ou e-mail + t.string :assunto + t.text :corpo + t.string :status, null: false, default: 'pendente' # pendente|enviado|falhou + t.text :erro + t.integer :tentativas, null: false, default: 0 + t.datetime :enviado_em + + t.timestamps + end + + add_index :notificacao_envios, %i[status created_at] + add_index :notificacao_envios, :created_at + end +end diff --git a/db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb b/db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb new file mode 100644 index 0000000..ebf1d9f --- /dev/null +++ b/db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb @@ -0,0 +1,30 @@ +# WhatsApp por sessĂŁo pareada com QR code (Baileys), no lugar do Twilio. +# +# `whatsapp_provedor` decide quem envia. Nasce com o valor que a instalação jĂĄ +# usa ('twilio' se havia credencial configurada), entĂŁo o deploy nĂŁo muda o +# comportamento atĂ© o ADM parear o QR e trocar o provedor na tela. +# +# ⚠ O envio por QR Ă© a porta do WhatsApp Web (engenharia reversa), fora dos +# Termos do WhatsApp: a Meta PODE banir o nĂșmero. Por isso o intervalo entre +# mensagens Ă© configurĂĄvel e nasce em 5s — disparo em rajada para dezenas de +# contatos Ă© a forma mais rĂĄpida de perder o nĂșmero. +class AddBaileysToConfiguracaoNotificacoes < ActiveRecord::Migration[7.1] + def up + change_table :configuracao_notificacoes, bulk: true do |t| + t.string :whatsapp_provedor, null: false, default: 'twilio' # twilio | baileys + t.string :baileys_url + t.text :baileys_token_cifrado + t.string :whatsapp_numero_conectado + t.datetime :whatsapp_conectado_em + t.integer :whatsapp_intervalo_segundos, null: false, default: 5 + end + end + + def down + change_table :configuracao_notificacoes, bulk: true do |t| + t.remove :whatsapp_provedor, :baileys_url, :baileys_token_cifrado, + :whatsapp_numero_conectado, :whatsapp_conectado_em, + :whatsapp_intervalo_segundos + end + end +end diff --git a/db/migrate/20260824000005_create_mensagem_templates.rb b/db/migrate/20260824000005_create_mensagem_templates.rb new file mode 100644 index 0000000..38a5f6e --- /dev/null +++ b/db/migrate/20260824000005_create_mensagem_templates.rb @@ -0,0 +1,29 @@ +# Corpo das mensagens montado pelo ADM, um template por EVENTO e por CANAL. +# +# Antes, o texto era string interpolada dentro do NotificacaoService: mudar uma +# palavra exigia deploy. Agora o ADM empilha blocos (cabeçalho, texto, tabela de +# valores, aviso, botĂŁo, rodapĂ©) na tela. +# +# `blocos` Ă© jsonb e nĂŁo tabela filha de propĂłsito: a ordem faz parte do dado +# (Ă© uma lista, nĂŁo um conjunto), o conteĂșdo de cada tipo de bloco Ă© diferente, e +# salvar o template inteiro numa transação evita estado meio-salvo. A validação +# da forma acontece no model (MensagemTemplate#normalizar_blocos), nĂŁo no banco. +class CreateMensagemTemplates < ActiveRecord::Migration[7.1] + def change + create_table :mensagem_templates do |t| + t.references :evento_notificacao, null: false, foreign_key: true + + t.string :canal, null: false # whatsapp | email + t.string :assunto # sĂł e-mail + t.jsonb :blocos, null: false, default: [] + t.boolean :ativo, null: false, default: true + + t.timestamps + end + + # Um template por evento+canal: dois templates ativos para o mesmo par + # deixariam o envio escolhendo em silĂȘncio. + add_index :mensagem_templates, %i[evento_notificacao_id canal], + unique: true, name: 'index_template_evento_canal_unico' + end +end diff --git a/db/migrate/20260824000006_create_operacao_snapshots.rb b/db/migrate/20260824000006_create_operacao_snapshots.rb new file mode 100644 index 0000000..c1ed802 --- /dev/null +++ b/db/migrate/20260824000006_create_operacao_snapshots.rb @@ -0,0 +1,27 @@ +# Retrato dos nĂșmeros de cada operação, para detectar o que MUDOU entre uma +# varredura e a seguinte. +# +# A maior parte das mudanças da operação nĂŁo passa pelo nosso cĂłdigo: elas +# acontecem no SimpliRoute e chegam pelo sync do espelho (que Ă© read-only aqui). +# Sem guardar o retrato anterior nĂŁo hĂĄ como dizer "3 NFs entraram, 1 saiu" — +# sĂł dĂĄ para mostrar o nĂșmero de agora, que Ă© o que os dashboards jĂĄ fazem. +# +# Uma linha por tabela de operação. É snapshot, nĂŁo histĂłrico: a linha Ă© +# ATUALIZADA a cada varredura. Guardar a sĂ©rie inteira seria outra feature (e a +# tabela cresceria por operação × varredura sem ninguĂ©m consultar). +class CreateOperacaoSnapshots < ActiveRecord::Migration[7.1] + def change + create_table :operacao_snapshots do |t| + t.string :tabela, null: false + t.integer :total_notas, null: false, default: 0 + t.integer :entregues, null: false, default: 0 + t.integer :nao_entregues, null: false, default: 0 + t.integer :pendentes, null: false, default: 0 + t.datetime :capturado_em, null: false + + t.timestamps + end + + add_index :operacao_snapshots, :tabela, unique: true + end +end diff --git a/db/migrate/20260824000007_semear_eventos_de_gatilho.rb b/db/migrate/20260824000007_semear_eventos_de_gatilho.rb new file mode 100644 index 0000000..5beb390 --- /dev/null +++ b/db/migrate/20260824000007_semear_eventos_de_gatilho.rb @@ -0,0 +1,35 @@ +# Cria os eventos de sistema dos gatilhos ligados na etapa 3. +# +# Sem isso, `Despachante.disparar(chave: 'valor_alterado')` nĂŁo acharia evento +# nenhum e o gatilho ficaria mudo — e o ADM teria de adivinhar que precisa criar +# um evento cujo nome gere exatamente aquela `chave`. +# +# Nascem ATIVOS e isso Ă© seguro: sem grupo assinante nĂŁo hĂĄ destinatĂĄrio, entĂŁo +# nada Ă© enviado atĂ© o ADM marcar um grupo na tela. +class SemearEventosDeGatilho < ActiveRecord::Migration[7.1] + EVENTOS = [ + ['Valor alterado no fechamento', 'valor_alterado', 'valor_alterado', + 'Disparado quando o valor de um motorista muda depois da consolidação finalizada.'], + ['Dados da operação mudaram', 'operacao_alterada', 'operacao_alterada', + 'NF que entrou ou saiu, entrega que trocou de status, lançamento corrigido no SimpliRoute.'] + ].freeze + + def up + EVENTOS.each do |nome, chave, gatilho, descricao| + # Idempotente: rodar de novo (ou num banco que jĂĄ tenha o evento criado Ă  + # mĂŁo) nĂŁo pode estourar Ă­ndice Ășnico. + next if select_value("SELECT 1 FROM eventos_notificacao WHERE chave = #{quote(chave)}") + + execute(<<~SQL.squish) + INSERT INTO eventos_notificacao + (nome, chave, gatilho, descricao, ativo, sistema, notificar_envolvido, hora, created_at, updated_at) + VALUES (#{quote(nome)}, #{quote(chave)}, #{quote(gatilho)}, #{quote(descricao)}, + true, true, true, 8, NOW(), NOW()) + SQL + end + end + + def down + EVENTOS.each { |_, chave, _, _| execute("DELETE FROM eventos_notificacao WHERE chave = #{quote(chave)}") } + end +end diff --git a/docker-compose.yml b/docker-compose.yml index 4bb6c02..1b20001 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,32 @@ services: command: bash -c "rm -f tmp/pids/server.pid && (bundle check || bundle install) && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0" # O banco PostgreSQL jĂĄ existe externamente. # Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL. + depends_on: + - whatsapp + + # Ponte com o WhatsApp (Baileys, sessĂŁo pareada por QR). Container separado + # porque nĂŁo existe biblioteca Ruby que fale o protocolo do WhatsApp Web — e + # porque isolar a sessĂŁo evita que uma queda dela derrube o Puma. + # + # ⚠ A porta NÃO Ă© publicada de propĂłsito: quem alcança Ă© sĂł o container do + # Rails, pela rede interna do compose. Publicar exporia um endpoint que + # manda mensagem em nome da empresa. + whatsapp: + build: + context: ./whatsapp + dockerfile: Dockerfile + restart: unless-stopped + environment: + WHATSAPP_TOKEN: ${WHATSAPP_TOKEN} + WHATSAPP_DATA_DIR: /data + PORT: 3001 + volumes: + # A sessĂŁo pareada vive aqui. Sem este volume, cada deploy exige escanear + # o QR de novo. + - "whatsapp_auth:/data" + expose: + - "3001" volumes: bundle_cache: + whatsapp_auth: diff --git a/lib/tasks/notificacao.rake b/lib/tasks/notificacao.rake new file mode 100644 index 0000000..3cbec55 --- /dev/null +++ b/lib/tasks/notificacao.rake @@ -0,0 +1,11 @@ +namespace :notificacao do + desc 'Dispara os eventos agendados que venceram (roda de hora em hora pelo cron)' + task agendados: :environment do + DispararEventosAgendadosJob.perform_now + end + + desc 'Compara os nĂșmeros das operaçÔes com o retrato anterior e avisa o que mudou' + task mudancas_operacao: :environment do + DetectarMudancasOperacaoJob.perform_now + end +end diff --git a/spec/factories.rb b/spec/factories.rb index c91a81d..2e8d06d 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -53,6 +53,34 @@ FactoryBot.define do valor_aplicado { 15.0 } end + factory :grupo_contato do + sequence(:nome) { |n| "Grupo #{n}" } + ativo { true } + end + + factory :contato do + sequence(:nome) { |n| "Contato #{n}" } + sequence(:telefone) { |n| format('11 9%04d-%04d', n % 10000, (n * 7) % 10000) } + ativo { true } + association :grupo_contato + end + + factory :evento_notificacao do + sequence(:nome) { |n| "Evento #{n}" } + sequence(:chave) { |n| "evento_#{n}" } + gatilho { 'manual' } + ativo { true } + notificar_envolvido { true } + hora { 8 } + end + + factory :grupo_evento_assinatura do + association :grupo_contato + association :evento_notificacao + canal { 'ambos' } + ativo { true } + end + factory :historico_estimado do data_hora { Time.current } valor_total_estimado { 150.0 } diff --git a/spec/jobs/detectar_mudancas_operacao_job_spec.rb b/spec/jobs/detectar_mudancas_operacao_job_spec.rb new file mode 100644 index 0000000..0b790cb --- /dev/null +++ b/spec/jobs/detectar_mudancas_operacao_job_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe DetectarMudancasOperacaoJob do + let(:tabela) { 'gade_entregas_teste' } + + before do + create(:evento_notificacao, gatilho: 'operacao_alterada', chave: 'operacao_alterada') + allow(Operacao).to receive(:nomes_validos).and_return([tabela]) + allow(Operacao).to receive(:label).with(tabela).and_return('TESTE') + allow(Notificacao::Gatilhos).to receive(:operacao_alterada) + end + + def com_numeros(total:, entregues:, nao_entregues:, pendentes:) + allow(Analytics::OperacaoMetricas).to receive(:new).and_return( + instance_double(Analytics::OperacaoMetricas, total: total, sucesso: entregues, + recusas: nao_entregues, pendentes: pendentes) + ) + end + + # Sem retrato anterior toda operação pareceria "nova", e o primeiro deploy + # dispararia uma mensagem por operação cadastrada. + it 'primeira execução sĂł grava o retrato, sem avisar' do + com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2) + + expect { described_class.perform_now }.to change(OperacaoSnapshot, :count).by(1) + expect(Notificacao::Gatilhos).not_to have_received(:operacao_alterada) + end + + it 'nĂŁo avisa quando nada mudou' do + com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2) + described_class.perform_now + + described_class.perform_now + + expect(Notificacao::Gatilhos).not_to have_received(:operacao_alterada) + end + + it 'descreve o que mudou entre os dois retratos' do + com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2) + described_class.perform_now + + com_numeros(total: 103, entregues: 92, nao_entregues: 8, pendentes: 3) + described_class.perform_now + + expect(Notificacao::Gatilhos).to have_received(:operacao_alterada) do |args| + expect(args[:operacao]).to eq('TESTE') + expect(args[:o_que_mudou]).to include('3 notas na operação a mais') + expect(args[:o_que_mudou]).to include('2 entregues a mais') + end + expect(OperacaoSnapshot.find_by(tabela: tabela).total_notas).to eq(103) + end + + it 'nĂŁo varre nada quando nenhum evento usa esse gatilho' do + EventoNotificacao.where(gatilho: 'operacao_alterada').destroy_all + expect(Analytics::OperacaoMetricas).not_to receive(:new) + + described_class.perform_now + end +end diff --git a/spec/jobs/disparar_eventos_agendados_job_spec.rb b/spec/jobs/disparar_eventos_agendados_job_spec.rb new file mode 100644 index 0000000..9ded4fe --- /dev/null +++ b/spec/jobs/disparar_eventos_agendados_job_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe DispararEventosAgendadosJob do + let!(:evento) do + create(:evento_notificacao, gatilho: 'agendado', frequencia: 'diaria', hora: 8, ativo: true) + end + let(:agora) { Time.zone.local(2026, 8, 24, 9, 0) } + + let(:resumo) do + instance_double(Analytics::ResumoOperacao, vazio?: false, + numeros: { 'entregues' => '10', 'recusas' => '1', 'pendentes' => '0', + 'retentativas' => '0', 'fora_operacao' => '0' }) + end + + before do + allow(Analytics::ResumoOperacao).to receive(:new).and_return(resumo) + allow(Notificacao::Despachante).to receive(:disparar) + end + + it 'dispara o evento vencido e marca o disparo' do + described_class.perform_now(agora) + + expect(Notificacao::Despachante).to have_received(:disparar) + expect(evento.reload.ultimo_disparo_em).to be_present + end + + # `vencido?` depende de ultimo_disparo_em; sem gravar, a varredura horĂĄria + # mandaria a mesma mensagem a cada hora atĂ© o dia virar. + it 'nĂŁo repete no mesmo dia' do + described_class.perform_now(agora) + described_class.perform_now(agora + 1.hour) + + expect(Notificacao::Despachante).to have_received(:disparar).once + end + + it 'nĂŁo dispara antes da hora configurada' do + described_class.perform_now(Time.zone.local(2026, 8, 24, 7, 0)) + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + it 'ignora evento desativado' do + evento.update!(ativo: false) + described_class.perform_now(agora) + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + # Resumo zerado sĂł treinaria o leitor a ignorar a mensagem — mas precisa + # marcar como disparado, senĂŁo tenta de hora em hora. + it 'perĂ­odo sem movimento: nĂŁo envia, mas marca o disparo' do + allow(resumo).to receive(:vazio?).and_return(true) + + described_class.perform_now(agora) + + expect(Notificacao::Despachante).not_to have_received(:disparar) + expect(evento.reload.ultimo_disparo_em).to be_present + end + + describe 'janela do resumo (recortes FECHADOS, para o nĂșmero nĂŁo mudar depois de enviado)' do + it 'diĂĄria cobre o dia anterior' do + described_class.perform_now(agora) + expect(Analytics::ResumoOperacao).to have_received(:new) + .with(inicio: Date.new(2026, 8, 23), fim: Date.new(2026, 8, 23)) + end + + it 'semanal cobre os 7 dias anteriores' do + evento.update!(frequencia: 'semanal', dia_semana: agora.to_date.wday) + described_class.perform_now(agora) + expect(Analytics::ResumoOperacao).to have_received(:new) + .with(inicio: Date.new(2026, 8, 17), fim: Date.new(2026, 8, 23)) + end + end +end diff --git a/spec/models/contato_spec.rb b/spec/models/contato_spec.rb new file mode 100644 index 0000000..7139c18 --- /dev/null +++ b/spec/models/contato_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe Contato do + it 'normaliza o telefone digitado para E.164 na gravação' do + # O ADM digita com mĂĄscara; mandar esse texto cru para o provedor devolve + # erro de destino invĂĄlido que o rescue do envio esconderia. + %w[(11)92005-1157 11920051157 011\ 92005\ 1157 +5511920051157].each do |digitado| + contato = build(:contato, telefone: digitado) + expect(contato).to be_valid + expect(contato.telefone).to eq('+5511920051157') + end + end + + it 'exige ao menos um canal' do + contato = build(:contato, telefone: nil, email: nil) + expect(contato).not_to be_valid + expect(contato.errors.full_messages.join).to include('WhatsApp ou um e-mail') + end + + it 'aceita contato sĂł de e-mail' do + expect(build(:contato, telefone: nil, email: 'diretoria@reem.com')).to be_valid + end + + it 'rejeita telefone que nĂŁo dĂĄ para normalizar, devolvendo o valor digitado' do + contato = build(:contato, telefone: '123') + expect(contato).not_to be_valid + expect(contato.errors[:telefone].join).to include('nĂșmero brasileiro') + # O ADM precisa ver o que digitou para corrigir. + expect(contato.telefone).to eq('123') + end + + describe '.com_canal' do + let!(:so_zap) { create(:contato, telefone: '11 92005-1157', email: nil) } + let!(:so_email) { create(:contato, telefone: nil, email: 'a@b.com') } + let!(:ambos) { create(:contato, telefone: '11 93005-1157', email: 'c@d.com') } + + it 'filtra por quem tem o canal' do + expect(Contato.com_canal('whatsapp')).to contain_exactly(so_zap, ambos) + expect(Contato.com_canal('email')).to contain_exactly(so_email, ambos) + expect(Contato.com_canal('ambos')).to contain_exactly(so_zap, so_email, ambos) + end + end +end diff --git a/spec/models/evento_notificacao_spec.rb b/spec/models/evento_notificacao_spec.rb new file mode 100644 index 0000000..6694719 --- /dev/null +++ b/spec/models/evento_notificacao_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe EventoNotificacao do + it 'gera a chave a partir do nome quando nĂŁo informada' do + evento = described_class.create!(nome: 'Aviso da Diretoria', gatilho: 'manual') + expect(evento.chave).to eq('aviso_da_diretoria') + end + + it 'exige dia da semana na frequĂȘncia semanal' do + evento = build(:evento_notificacao, gatilho: 'agendado', frequencia: 'semanal', dia_semana: nil) + expect(evento).not_to be_valid + expect(evento.errors[:dia_semana]).to be_present + end + + it 'evento de sistema nĂŁo Ă© apagĂĄvel' do + expect(build(:evento_notificacao, sistema: true).apagavel?).to be(false) + expect(build(:evento_notificacao, sistema: false).apagavel?).to be(true) + end + + describe '#destinatarios' do + let(:evento) { create(:evento_notificacao) } + let(:diretoria) { create(:grupo_contato) } + let(:operacao) { create(:grupo_contato) } + + it 'junta os contatos de todos os grupos assinantes, sem repetir' do + a = create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157') + b = create(:contato, grupo_contato: operacao, telefone: '11 93005-1157') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + create(:grupo_evento_assinatura, grupo_contato: operacao, evento_notificacao: evento) + + expect(evento.reload.destinatarios('whatsapp')).to contain_exactly(a, b) + end + + it 'respeita o canal da assinatura' do + so_email = create(:contato, grupo_contato: diretoria, telefone: nil, email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'email') + + expect(evento.reload.destinatarios('email')).to contain_exactly(so_email) + expect(evento.reload.destinatarios('whatsapp')).to be_empty + end + + it 'ignora assinatura desativada e contato desativado' do + create(:contato, grupo_contato: diretoria, ativo: false) + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, ativo: false) + + expect(evento.reload.destinatarios('whatsapp')).to be_empty + end + end + + describe '#vencido?' do + let(:evento) do + create(:evento_notificacao, gatilho: 'agendado', frequencia: 'diaria', hora: 8) + end + + it 'vence depois da hora e sĂł uma vez por dia' do + manha = Time.zone.local(2026, 8, 24, 9, 0) + expect(evento.vencido?(manha)).to be(true) + + evento.update!(ultimo_disparo_em: Time.zone.local(2026, 8, 24, 8, 5)) + expect(evento.vencido?(manha)).to be(false) + + expect(evento.vencido?(Time.zone.local(2026, 8, 25, 9, 0))).to be(true) + end + + it 'nĂŁo vence antes da hora' do + expect(evento.vencido?(Time.zone.local(2026, 8, 24, 7, 0))).to be(false) + end + end +end diff --git a/spec/models/mensagem_template_spec.rb b/spec/models/mensagem_template_spec.rb new file mode 100644 index 0000000..f881d05 --- /dev/null +++ b/spec/models/mensagem_template_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe MensagemTemplate do + let(:evento) { create(:evento_notificacao, gatilho: 'pagamento_efetuado') } + + def montar(blocos, **extra) + described_class.new({ evento_notificacao: evento, canal: 'whatsapp', blocos: blocos }.merge(extra)) + end + + # `blocos` vem de um hidden preenchido por JavaScript — ou seja, de fora. + describe 'limpeza dos blocos na gravação' do + it 'descarta tipo fora do catĂĄlogo' do + t = montar([{ 'tipo' => 'iframe', 'texto' => 'x' }, { 'tipo' => 'texto', 'texto' => 'ok' }]) + t.validate + expect(t.blocos.map { |b| b['tipo'] }).to eq(['texto']) + end + + it 'descarta campo que o tipo nĂŁo tem' do + t = montar([{ 'tipo' => 'divisor', 'onload' => 'alert(1)' }]) + t.validate + expect(t.blocos.first.keys).to eq(['tipo']) + end + + it 'descarta linha de tabela sem rĂłtulo nem valor' do + t = montar([{ 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => '', 'valor' => '' }, + { 'rotulo' => 'Valor', 'valor' => '10' }] }]) + t.validate + expect(t.blocos.first['linhas'].size).to eq(1) + end + + it 'limita a quantidade de blocos' do + t = montar(Array.new(60) { { 'tipo' => 'divisor' } }) + t.validate + expect(t.blocos.size).to eq(described_class::MAX_BLOCOS) + end + + it 'aceita blocos com chave sĂ­mbolo (vindos de cĂłdigo, nĂŁo do form)' do + t = montar([{ tipo: 'texto', texto: 'oi' }]) + t.validate + expect(t.blocos).to eq([{ 'tipo' => 'texto', 'texto' => 'oi' }]) + end + end + + it 'exige assunto no e-mail com conteĂșdo' do + t = montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], canal: 'email', assunto: '') + expect(t).not_to be_valid + expect(t.errors[:assunto]).to be_present + end + + # Template ativo e vazio mandaria mensagem em branco — pior que o texto padrĂŁo. + it 'template vazio nĂŁo Ă© utilizĂĄvel mesmo estando ativo' do + expect(montar([], ativo: true).utilizavel?).to be(false) + expect(montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], ativo: true).utilizavel?).to be(true) + expect(montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], ativo: false).utilizavel?).to be(false) + end + + it 'nĂŁo deixa dois templates do mesmo canal no mesmo evento' do + described_class.create!(evento_notificacao: evento, canal: 'whatsapp', blocos: []) + duplicado = described_class.new(evento_notificacao: evento, canal: 'whatsapp', blocos: []) + expect(duplicado).not_to be_valid + end + + it 'preview usa os valores de amostra do gatilho' do + t = montar([{ 'tipo' => 'texto', 'texto' => 'Valor: {{valor}}' }]) + expect(t.preview).to include(Notificacao::Variaveis.amostra('pagamento_efetuado')['valor']) + end +end diff --git a/spec/services/notificacao/despachante_spec.rb b/spec/services/notificacao/despachante_spec.rb new file mode 100644 index 0000000..5bdf862 --- /dev/null +++ b/spec/services/notificacao/despachante_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe Notificacao::Despachante do + let(:evento) { create(:evento_notificacao) } + let(:diretoria) { create(:grupo_contato) } + let(:config) { ConfiguracaoNotificacao.instancia } + + before do + config.update_columns(whatsapp_ativo: true, whatsapp_provedor: 'baileys', + email_notificacoes_ativo: true, whatsapp_intervalo_segundos: 0) + # Nunca tocar a ponte de verdade no teste. + allow(Notificacao::Whatsapp).to receive(:enviar) + .and_return(Notificacao::ClienteWhatsapp::Resposta.new(ok: true)) + allow(ConfiguracaoNotificacao).to receive(:instancia).and_return(config) + end + + def despachar(envolvido: nil, dados: {}) + described_class.new(evento.reload, dados: dados, assunto: 'Assunto', + corpo: 'Corpo', envolvido: envolvido).executar + end + + it 'envia para os contatos dos grupos assinantes e registra cada envio' do + contato = create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + + expect { despachar }.to change(NotificacaoEnvio, :count).by(2) # whatsapp + email + + expect(Notificacao::Whatsapp).to have_received(:enviar) + .with(hash_including(para: contato.telefone)) + expect(NotificacaoEnvio.where(canal: 'whatsapp').first).to be_enviado + end + + # A mesma pessoa pode ser contato de um grupo E a envolvida no fato — sem + # dedup ela receberia a mensagem duas vezes. + it 'nĂŁo manda duas vezes para o mesmo nĂșmero' do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: nil) + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'whatsapp') + motorista = create(:motorista, telefone: '(11) 92005-1157') + + despachar(envolvido: motorista) + + expect(NotificacaoEnvio.where(canal: 'whatsapp').count).to eq(1) + end + + it 'inclui o envolvido que nĂŁo Ă© contato de nenhum grupo' do + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + motorista = create(:motorista, telefone: '(11) 98888-7777') + + despachar(envolvido: motorista) + + expect(NotificacaoEnvio.where(canal: 'whatsapp').pluck(:destino)).to eq(['+5511988887777']) + end + + it 'nĂŁo avisa o envolvido quando o evento diz para nĂŁo avisar' do + evento.update!(notificar_envolvido: false) + motorista = create(:motorista, telefone: '(11) 98888-7777') + + despachar(envolvido: motorista) + + expect(NotificacaoEnvio.where(canal: 'whatsapp')).to be_empty + end + + it 'registra a falha em vez de levantar — envio nĂŁo pode travar um fechamento' do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: nil) + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'whatsapp') + allow(Notificacao::Whatsapp).to receive(:enviar) + .and_return(Notificacao::ClienteWhatsapp::Resposta.new(ok: false, erro: 'whatsapp desconectado')) + + expect { despachar }.not_to raise_error + + envio = NotificacaoEnvio.last + expect(envio).to be_falhou + expect(envio.erro).to include('desconectado') + end + + # O editor de blocos vence o texto padrĂŁo — e o fallback existe para que ligar + # o editor nĂŁo apague as notificaçÔes dos eventos que jĂĄ funcionavam. + describe 'template de blocos' do + before do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + end + + it 'sem template, usa o corpo passado pelo chamador' do + despachar + expect(NotificacaoEnvio.find_by(canal: 'whatsapp').corpo).to eq('Corpo') + end + + it 'com template, o corpo sai renderizado por canal' do + MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true, + blocos: [{ 'tipo' => 'texto', 'texto' => 'Oi {{contato}}, {{valor}}' }]) + MensagemTemplate.create!(evento_notificacao: evento, canal: 'email', ativo: true, + assunto: 'Fechamento de {{contato}}', + blocos: [{ 'tipo' => 'texto', 'texto' => 'Corpo do e-mail' }]) + + despachar(dados: { 'valor' => 'R$ 10,00' }) + + zap = NotificacaoEnvio.find_by(canal: 'whatsapp') + expect(zap.corpo).to include('R$ 10,00') + + email = NotificacaoEnvio.find_by(canal: 'email') + expect(email.corpo).to include(' 'texto', 'texto' => 'OlĂĄ {{contato}}' }]) + + despachar + + corpos = NotificacaoEnvio.where(canal: 'whatsapp').pluck(:corpo) + expect(corpos).to include(a_string_including('Ana')) + expect(corpos.uniq.size).to eq(corpos.size) + end + + it 'template ativo mas vazio nĂŁo substitui o texto padrĂŁo' do + MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true, blocos: []) + despachar + expect(NotificacaoEnvio.find_by(canal: 'whatsapp').corpo).to eq('Corpo') + end + end + + it 'nĂŁo envia nada quando o canal estĂĄ desligado na configuração' do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + config.update_columns(whatsapp_ativo: false, email_notificacoes_ativo: false) + + expect { despachar }.not_to change(NotificacaoEnvio, :count) + end +end diff --git a/spec/services/notificacao/gatilhos_spec.rb b/spec/services/notificacao/gatilhos_spec.rb new file mode 100644 index 0000000..1580548 --- /dev/null +++ b/spec/services/notificacao/gatilhos_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe Notificacao::Gatilhos do + before { allow(Notificacao::Despachante).to receive(:disparar) } + + describe '.valor_alterado' do + let(:consolidacao) { create(:consolidacao, status: :finalizada) } + let(:cm) { create(:consolidacao_motorista, consolidacao: consolidacao, valor_total: 5060.0) } + + # Em rascunho o valor muda a cada clique do wizard — avisar ali seria spam. + it 'nĂŁo dispara enquanto a consolidação Ă© rascunho' do + rascunho = create(:consolidacao, status: :rascunho) + cm_rasc = create(:consolidacao_motorista, consolidacao: rascunho, valor_total: 100.0) + + described_class.valor_alterado(rascunho, cm_rasc, anterior: 50.0) + + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + it 'nĂŁo dispara quando o valor nĂŁo mudou' do + described_class.valor_alterado(consolidacao, cm, anterior: 5060.0) + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + it 'descreve a mudança em {{o_que_mudou}}' do + described_class.valor_alterado(consolidacao, cm, anterior: 4900.0) + + expect(Notificacao::Despachante).to have_received(:disparar) do |args| + expect(args[:chave]).to eq('valor_alterado') + expect(args[:dados]['o_que_mudou']).to eq('Valor passou de R$ 4.900,00 para R$ 5.060,00') + expect(args[:dados]['valor']).to eq('R$ 5.060,00') + end + end + + it 'nĂŁo levanta quando algo dĂĄ errado — nĂŁo pode travar um fechamento' do + allow(Notificacao::Despachante).to receive(:disparar).and_raise(StandardError, 'boom') + expect { described_class.valor_alterado(consolidacao, cm, anterior: 1.0) }.not_to raise_error + end + end + + describe '.operacao_alterada' do + it 'monta as variĂĄveis do gatilho' do + described_class.operacao_alterada(operacao: 'UBS NORTE AGO 2026', + o_que_mudou: '3 notas na operação a mais', nf: '85382') + + expect(Notificacao::Despachante).to have_received(:disparar) do |args| + expect(args[:chave]).to eq('operacao_alterada') + expect(args[:dados]['operacao']).to eq('UBS NORTE AGO 2026') + expect(args[:dados]['nf']).to eq('85382') + end + end + end + + # Toda variĂĄvel que o editor anuncia precisa de par aqui: anunciada e nĂŁo + # preenchida sai vazia na mensagem, o que Ă© pior do que nĂŁo existir. + describe 'cobertura das variĂĄveis anunciadas no editor' do + it 'valor_alterado preenche tudo que Variaveis oferece' do + consolidacao = create(:consolidacao, status: :finalizada) + cm = create(:consolidacao_motorista, consolidacao: consolidacao, valor_total: 10.0) + + described_class.valor_alterado(consolidacao, cm, anterior: 1.0) + + expect(Notificacao::Despachante).to have_received(:disparar) do |args| + esperadas = Notificacao::Variaveis.nomes('valor_alterado') - ['contato'] # vem por destinatĂĄrio + expect(args[:dados].keys).to include(*esperadas) + end + end + end +end diff --git a/spec/services/notificacao/renderizador_spec.rb b/spec/services/notificacao/renderizador_spec.rb new file mode 100644 index 0000000..062854f --- /dev/null +++ b/spec/services/notificacao/renderizador_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe Notificacao::Renderizador do + let(:dados) { { 'contato' => 'Carlos', 'valor' => 'R$ 5.060,00', 'entregas' => '280' } } + + def render(blocos) = described_class.new(blocos, dados) + + describe 'as duas saĂ­das do mesmo template' do + let(:blocos) do + [ + { 'tipo' => 'cabecalho', 'titulo' => 'Pagamento fechado', 'subtitulo' => 'UBS Norte' }, + { 'tipo' => 'texto', 'texto' => 'OlĂĄ {{contato}}!' }, + { 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => 'Entregas', 'valor' => '{{entregas}}' }, + { 'rotulo' => 'Valor', 'valor' => '{{valor}}' }] }, + { 'tipo' => 'botao', 'rotulo' => 'Ver extrato', 'url' => 'https://app.reem.com.br/motorista' } + ] + end + + it 'monta o texto do WhatsApp com negrito e bullets' do + texto = render(blocos).texto + expect(texto).to include('*Pagamento fechado*') + expect(texto).to include('OlĂĄ Carlos!') + expect(texto).to include('‱ Entregas: 280') + expect(texto).to include('Ver extrato: https://app.reem.com.br/motorista') + end + + it 'monta o HTML do e-mail com estilo inline' do + html = render(blocos).html + expect(html).to include(' 'texto', 'texto' => 'Oi {{inexistente}}!' }]).texto + expect(texto).to eq('Oi !') + end + + # O corpo Ă© digitado numa tela e o preview usa este mesmo renderizador — um + # escape faltando atingiria primeiro o prĂłprio admin. + it 'escapa HTML do texto digitado e do valor da variĂĄvel' do + r = described_class.new( + [{ 'tipo' => 'texto', 'texto' => ' {{contato}}' }], + { 'contato' => '' } + ) + html = r.html + expect(html).not_to include(' diff --git a/app/views/admin/contatos/index.html.erb b/app/views/admin/contatos/index.html.erb index 624898a..12fa0bb 100644 --- a/app/views/admin/contatos/index.html.erb +++ b/app/views/admin/contatos/index.html.erb @@ -42,8 +42,19 @@ <% @contatos.each do |contato| %> - <%= contato.nome %> - <%= contato.telefone.presence || '—' %> + + <%= contato.nome %> + <% if contato.grupo_whatsapp? %> + grupo + <% end %> + + + <% if contato.grupo_whatsapp? %> + <%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %> + <% else %> + <%= contato.telefone.presence || '—' %> + <% end %> + <%= contato.email.presence || '—' %> <% if contato.grupo_contato %> diff --git a/config/routes.rb b/config/routes.rb index 0d925e6..ec50104 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -121,7 +121,12 @@ Rails.application.routes.draw do # singular igual ao plural para o Inflector (que nĂŁo fala portuguĂȘs), e aĂ­ # o Rails sufixa o helper de index com `_index` — pegadinha silenciosa. resources :grupos, except: [:show], controller: 'grupos_contato' - resources :contatos, except: [:show] + resources :contatos, except: [:show] do + # Grupos do WhatsApp de que o nĂșmero conectado participa (JSON). Carregado + # sob demanda pelo form: se fosse no render da tela, uma ponte fora do ar + # faria o cadastro de contato inteiro esperar o timeout. + collection { get :grupos_whatsapp } + end resources :eventos, except: [:show], controller: 'eventos_notificacao' do member do post :disparar # gatilho `manual` — manda mensagem de verdade diff --git a/db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb b/db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb new file mode 100644 index 0000000..8ca374b --- /dev/null +++ b/db/migrate/20260824000008_add_grupo_whatsapp_to_contatos.rb @@ -0,0 +1,27 @@ +# Permite que um destinatĂĄrio seja um GRUPO do WhatsApp, e nĂŁo sĂł uma pessoa. +# +# O grupo entra como Contato de propĂłsito: assim ele herda tudo que jĂĄ existe — +# pertence a um grupo de contatos, recebe os eventos que o grupo assina e cada +# envio fica registrado em notificacao_envios. Uma tabela separada duplicaria +# essa mĂĄquina inteira. +# +# `whatsapp_grupo_jid` guarda o identificador do WhatsApp (ex.: +# "120363012345678901@g.us"). NÃO Ă© telefone: nĂŁo tem DDD, nĂŁo normaliza, e a +# validação de nĂșmero nĂŁo se aplica — daĂ­ a coluna prĂłpria em vez de reaproveitar +# `telefone`. +# +# ⚠ Nome: `grupo_contato_id` Ă© o GRUPO INTERNO (Diretoria, Motoristas...); +# `whatsapp_grupo_*` Ă© o grupo lĂĄ no WhatsApp. Coisas diferentes. +class AddGrupoWhatsappToContatos < ActiveRecord::Migration[7.1] + def change + change_table :contatos, bulk: true do |t| + t.string :tipo, null: false, default: 'pessoa' # pessoa | grupo_whatsapp + t.string :whatsapp_grupo_jid + # Nome do grupo no momento do cadastro: o JID Ă© opaco e a tela precisa + # mostrar algo legĂ­vel mesmo com a ponte fora do ar. + t.string :whatsapp_grupo_nome + end + + add_index :contatos, :whatsapp_grupo_jid + end +end diff --git a/spec/models/contato_spec.rb b/spec/models/contato_spec.rb index 7139c18..61fdce6 100644 --- a/spec/models/contato_spec.rb +++ b/spec/models/contato_spec.rb @@ -41,3 +41,50 @@ RSpec.describe Contato do end end end + +RSpec.describe Contato, 'grupo do WhatsApp' do + let(:jid) { '120363012345678901@g.us' } + + def grupo(**extra) + build(:contato, { tipo: 'grupo_whatsapp', nome: 'Motoristas SP', + whatsapp_grupo_jid: jid, telefone: nil }.merge(extra)) + end + + it 'aceita grupo sem telefone nem e-mail' do + expect(grupo).to be_valid + expect(grupo.destino_whatsapp).to eq(jid) + end + + it 'recusa identificador que nĂŁo Ă© de grupo' do + contato = grupo(whatsapp_grupo_jid: '5511920051157') + expect(contato).not_to be_valid + expect(contato.errors[:whatsapp_grupo_jid].join).to include('@g.us') + end + + # Cadastro meio pessoa, meio grupo mandaria a mesma mensagem duas vezes. + it 'limpa telefone e e-mail ao virar grupo' do + contato = grupo(telefone: '11 92005-1157', email: 'x@y.com') + contato.validate + expect(contato.telefone).to be_nil + expect(contato.email).to be_nil + end + + it 'limpa o grupo ao voltar a ser pessoa' do + contato = build(:contato, tipo: 'pessoa', telefone: '11 92005-1157', + whatsapp_grupo_jid: jid, whatsapp_grupo_nome: 'Motoristas') + contato.validate + expect(contato.whatsapp_grupo_jid).to be_nil + end + + # Cortar "Motoristas SP" em "Motoristas" no {{contato}} sĂł empobreceria o texto. + it '{{contato}} usa o nome inteiro do grupo' do + expect(grupo.primeiro_nome).to eq('Motoristas SP') + expect(build(:contato, nome: 'Carlos Matheus').primeiro_nome).to eq('Carlos') + end + + it 'entra no canal whatsapp e fica fora do canal e-mail' do + salvo = grupo.tap(&:save!) + expect(Contato.com_canal('whatsapp')).to include(salvo) + expect(Contato.com_canal('email')).not_to include(salvo) + end +end diff --git a/spec/services/notificacao/whatsapp_spec.rb b/spec/services/notificacao/whatsapp_spec.rb index a5174c4..6fb535a 100644 --- a/spec/services/notificacao/whatsapp_spec.rb +++ b/spec/services/notificacao/whatsapp_spec.rb @@ -33,3 +33,26 @@ RSpec.describe Notificacao::Whatsapp do expect(resposta.erro).to include('rede fora') end end + +RSpec.describe Notificacao::Whatsapp, 'grupo do WhatsApp' do + let(:jid) { '120363012345678901@g.us' } + + it 'manda o JID cru para a ponte, sem tentar normalizar como telefone' do + config = ConfiguracaoNotificacao.new(whatsapp_ativo: true, whatsapp_provedor: 'baileys') + ponte = instance_double(Notificacao::ClienteWhatsapp, + enviar: Notificacao::ClienteWhatsapp::Resposta.new(ok: true)) + allow(Notificacao::ClienteWhatsapp).to receive(:padrao).and_return(ponte) + + expect(described_class.enviar(para: jid, texto: 'oi', config: config)).to be_ok + expect(ponte).to have_received(:enviar).with(para: jid, texto: 'oi') + end + + it 'recusa com mensagem clara quando o provedor Ă© Twilio' do + config = ConfiguracaoNotificacao.new(whatsapp_ativo: true, whatsapp_provedor: 'twilio') + + resposta = described_class.enviar(para: jid, texto: 'oi', config: config) + + expect(resposta).not_to be_ok + expect(resposta.erro).to include('grupo') + end +end -- 2.49.1 From 985e2e3b55838ca81382fd840f38cdf7f2055f09568e083917090e5e2982d4e3 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 25 Aug 2026 00:41:26 -0300 Subject: [PATCH 09/40] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20para=20que=20poss?= =?UTF-8?q?a=20mandar=20=20mesagens=20do=20Zap=20para=20grupos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- app/models/configuracao_notificacao.rb | 7 +- app/services/notificacao/cliente_whatsapp.rb | 6 + .../configuracao_notificacoes/show.html.erb | 14 +- app/views/admin/contatos/_form.html.erb | 171 ++++++++----- app/views/admin/contatos/edit.html.erb | 10 +- app/views/admin/contatos/index.html.erb | 189 ++++++++++----- app/views/admin/contatos/new.html.erb | 10 +- .../admin/eventos_notificacao/_form.html.erb | 224 ++++++++++++------ .../admin/eventos_notificacao/edit.html.erb | 10 +- .../admin/eventos_notificacao/index.html.erb | 169 +++++++++---- .../admin/eventos_notificacao/new.html.erb | 10 +- app/views/admin/grupos_contato/_form.html.erb | 59 +++-- app/views/admin/grupos_contato/edit.html.erb | 8 +- app/views/admin/grupos_contato/index.html.erb | 148 +++++++----- app/views/admin/grupos_contato/new.html.erb | 8 +- .../admin/mensagem_templates/edit.html.erb | 121 +++++++--- .../admin/notificacao_envios/index.html.erb | 195 +++++++++------ .../admin/whatsapp_sessoes/show.html.erb | 101 +++++--- 19 files changed, 971 insertions(+), 494 deletions(-) diff --git a/.env.example b/.env.example index c3b4c29..35c17f0 100644 --- a/.env.example +++ b/.env.example @@ -79,10 +79,11 @@ NOTIFICACAO_SECRET= APP_HOST=localhost:3000 APP_NAME=Reem LogĂ­stica -# ── WhatsApp (Baileys — sessĂŁo pareada por QR code) ──────────────────────── +# ── WhatsApp (ponte prĂłpria — sessĂŁo pareada por QR code) ────────────────── # Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string # longa e aleatĂłria: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO. WHATSAPP_TOKEN=troque_por_uma_string_aleatoria_longa # URL da ponte dentro da rede do compose (nĂŁo mexa, a menos que troque o nome # do serviço no docker-compose.yml). -BAILEYS_URL=http://whatsapp:3001 +# (o nome antigo `BAILEYS_URL` ainda Ă© aceito, para .env jĂĄ em produção) +WHATSAPP_URL=http://whatsapp:3001 diff --git a/app/models/configuracao_notificacao.rb b/app/models/configuracao_notificacao.rb index 5ff8836..55d1ad4 100644 --- a/app/models/configuracao_notificacao.rb +++ b/app/models/configuracao_notificacao.rb @@ -123,8 +123,13 @@ class ConfiguracaoNotificacao < ApplicationRecord def baileys_exigido? = whatsapp_ativo? && baileys? # URL e token da ponte: banco > .env, mesma hierarquia do resto da tela. + # + # `WHATSAPP_URL` Ă© o nome atual — a tela e o .env.example falam em "ponte do + # WhatsApp", nĂŁo no nome da biblioteca. `BAILEYS_URL` continua sendo lido para + # nĂŁo quebrar quem jĂĄ tem a variĂĄvel antiga no .env em produção. def baileys_url_efetiva - baileys_url.presence || ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO + baileys_url.presence || ENV['WHATSAPP_URL'].presence || + ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO end def baileys_token_efetivo diff --git a/app/services/notificacao/cliente_whatsapp.rb b/app/services/notificacao/cliente_whatsapp.rb index 5c0581c..bbc57d4 100644 --- a/app/services/notificacao/cliente_whatsapp.rb +++ b/app/services/notificacao/cliente_whatsapp.rb @@ -97,6 +97,12 @@ module Notificacao def mensagem_de_erro(resposta, dados) return 'Token da ponte do WhatsApp invĂĄlido.' if resposta.code == '401' return 'WhatsApp desconectado — leia o QR code na tela.' if resposta.code == '503' + # 404 = a ponte respondeu, mas nĂŁo conhece a rota: a imagem do container + # `whatsapp` Ă© anterior a ela. "rota desconhecida" cru na tela nĂŁo diz o + # que fazer; o conserto Ă© rebuildar a ponte, nĂŁo mexer no Rails. + if resposta.code == '404' + return 'Ponte do WhatsApp desatualizada (nĂŁo conhece esta rota) — refaça o build do container `whatsapp`.' + end dados['erro'].presence || "HTTP #{resposta.code}" end diff --git a/app/views/admin/configuracao_notificacoes/show.html.erb b/app/views/admin/configuracao_notificacoes/show.html.erb index 8714221..8487387 100644 --- a/app/views/admin/configuracao_notificacoes/show.html.erb +++ b/app/views/admin/configuracao_notificacoes/show.html.erb @@ -181,7 +181,7 @@
- <%# Provedor: sem este seletor as colunas do Baileys existiam no banco mas + <%# Provedor: sem este seletor as colunas da ponte existiam no banco mas não havia como sair do Twilio pela tela — o pareamento por QR ficava inalcançável. %>
@@ -206,13 +206,15 @@
- <%# ── Modo QR (Baileys) ───────────────────────────────── %> + <%# ── Modo QR: a ponte própria do WhatsApp ─────────────── %>
-

Modo QR code

+

+ <%= icone :telefone, tamanho: 'w-4 h-4', espaco: false %> Modo QR code +

⚠ Canal nĂŁo oficial — a Meta pode banir o nĂșmero. Use um chip dedicado. Em branco, os dois campos abaixo usam - BAILEYS_URL e + WHATSAPP_URL e WHATSAPP_TOKEN do .env.

@@ -230,7 +232,9 @@
-

Modo Twilio

+

+ <%= icone :chave, tamanho: 'w-4 h-4', espaco: false %> Modo Twilio +

Credenciais em Twilio Console → Account Info

diff --git a/app/views/admin/contatos/_form.html.erb b/app/views/admin/contatos/_form.html.erb index 0f0a5ad..d4c02db 100644 --- a/app/views/admin/contatos/_form.html.erb +++ b/app/views/admin/contatos/_form.html.erb @@ -1,8 +1,27 @@ -<%= form_with model: [:admin, contato], data: { turbo: false }, class: 'space-y-5' do |f| %> +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + ajuda = 'text-gray-500 text-xs mt-1.5' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" +%> +<%= form_with model: [:admin, contato], data: { turbo: false }, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' do |f| %> + <% if contato.errors.any? %> -
+
+

+ <%= pluralize(contato.errors.count, 'erro', 'erros') %> encontrado<%= contato.errors.count > 1 ? 's' : '' %>: +

    - <% contato.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% contato.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %> @@ -10,42 +29,46 @@ <%# Pessoa x grupo do WhatsApp. O JavaScript no fim troca os blocos; sem JS a página continua utilizável — os dois blocos ficam visíveis e o model limpa o que não pertence ao tipo escolhido. %> -
- <%= f.label :tipo, 'Tipo de destinatĂĄrio', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.select :tipo, - [['Pessoa (nĂșmero ou e-mail)', 'pessoa'], ['Grupo do WhatsApp', 'grupo_whatsapp']], {}, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500', - id: 'contato-tipo' %> -
- -
- <%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :nome, required: true, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> -

Como aparece nas listas e em {{contato}}.

-
- -
-
- <%= f.label :telefone, 'WhatsApp', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :telefone, placeholder: '(11) 92005-1157', - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> -

Pode digitar com mĂĄscara — Ă© normalizado ao salvar.

+
+
+ <%= f.label :tipo, 'Tipo de destinatĂĄrio', class: rotulo %> + <%= f.select :tipo, + [['Pessoa (nĂșmero ou e-mail)', 'pessoa'], ['Grupo do WhatsApp', 'grupo_whatsapp']], {}, + class: "#{input} cursor-pointer", id: 'contato-tipo' %>
+
- <%= f.label :email, 'E-mail', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.email_field :email, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> + <%= f.label :nome, class: rotulo %> + <%= f.text_field :nome, required: true, placeholder: 'Maria Souza', class: input %> +

Como aparece nas listas e em {{contato}}.

+ <%# ── Pessoa ──────────────────────────────────────────────── %> +
+

Como falar com esta pessoa

+
+
+ <%= f.label :telefone, 'WhatsApp', class: rotulo %> + <%= f.text_field :telefone, placeholder: '(11) 92005-1157', class: input %> +

Pode digitar com mĂĄscara — Ă© normalizado ao salvar.

+
+
+ <%= f.label :email, 'E-mail', class: rotulo %> + <%= f.email_field :email, placeholder: 'maria@empresa.com', class: input %> +

Sem e-mail, este contato sĂł recebe WhatsApp.

+
+
+
+ + <%# ── Grupo do WhatsApp ───────────────────────────────────── %> <%# O identificador do grupo Ă© opaco ("120363
@g.us") — digitar na mĂŁo Ă© pedir erro. A lista vem da ponte, com o nĂșmero jĂĄ conectado. %> -
- <%= f.label :whatsapp_grupo_jid, 'Grupo do WhatsApp', class: 'block text-gray-400 text-sm mb-1' %> -
- <% if contato.whatsapp_grupo_jid.present? %>
<%= f.hidden_field :whatsapp_grupo_nome, id: 'grupo-nome' %> -

+

SĂł aparecem grupos em que o nĂșmero conectado jĂĄ Ă© membro. Grupo com "somente administradores" recusa o envio se o nĂșmero nĂŁo for admin.

-
- <%= f.label :grupo_contato_id, 'Grupo', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.collection_select :grupo_contato_id, @grupos, :id, :nome, { include_blank: 'Sem grupo (nĂŁo recebe nada)' }, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> -

É o grupo que define quais eventos este contato recebe.

+ <%# ── Assinatura e situação ───────────────────────────────── %> +
+
+ <%= f.label :grupo_contato_id, 'Grupo', class: rotulo %> + <%= f.collection_select :grupo_contato_id, @grupos, :id, :nome, + { include_blank: 'Sem grupo (nĂŁo recebe nada)' }, class: "#{input} cursor-pointer" %> +

É o grupo que define quais eventos este contato recebe.

+
+ +
+ <%= f.label :observacao, 'Observação', class: rotulo %> + <%= f.text_field :observacao, placeholder: 'Opcional', class: input %> +
+ +
+
+ <%= f.label :ativo, 'Contato ativo', class: 'block text-sm font-medium text-gray-300' %> +

Contato inativo continua cadastrado, mas nĂŁo recebe nada

+
+ +
-
- <%= f.label :observacao, 'Observação', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :observacao, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> -
- - - -
- <%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> - <%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %> +
+ <%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit contato.new_record? ? 'Criar Contato' : 'Salvar AlteraçÔes', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<% end %> @@ -101,6 +139,10 @@ var botao = document.getElementById('btn-carregar-grupos'); if (!tipo || !pessoa || !grupo) return; + // O rĂłtulo do botĂŁo vive num prĂłprio: trocar o textContent do botĂŁo + // inteiro apagaria o Ă­cone junto. + var botaoTexto = botao.querySelector('[data-btn-texto]'); + function alternar() { var ehGrupo = tipo.value === 'grupo_whatsapp'; pessoa.classList.toggle('hidden', ehGrupo); @@ -109,6 +151,15 @@ tipo.addEventListener('change', alternar); alternar(); + // Estado do aviso: erro em vermelho, sucesso em verde, neutro em cinza — + // o texto sozinho nĂŁo dizia se a busca deu certo. + function avisar(texto, estado) { + aviso.textContent = texto; + aviso.className = 'text-xs mt-1.5 ' + ( + estado === 'erro' ? 'text-red-400' : estado === 'ok' ? 'text-green-400' : 'text-gray-500' + ); + } + // O nome do grupo Ă© gravado junto com o id: a tela precisa mostrar algo // legĂ­vel mesmo com a ponte fora do ar (o id sozinho nĂŁo diz nada). select.addEventListener('change', function () { @@ -118,13 +169,13 @@ botao.addEventListener('click', function () { botao.disabled = true; - botao.textContent = 'Buscando
'; + botaoTexto.textContent = 'Buscando
'; fetch(grupo.dataset.url, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.json(); }) .then(function (dados) { - if (dados.erro) { aviso.textContent = dados.erro; return; } + if (dados.erro) { avisar(dados.erro, 'erro'); return; } if (!dados.grupos.length) { - aviso.textContent = 'Nenhum grupo encontrado — o nĂșmero conectado nĂŁo participa de nenhum.'; + avisar('Nenhum grupo encontrado — o nĂșmero conectado nĂŁo participa de nenhum.', 'erro'); return; } select.innerHTML = ''; @@ -138,10 +189,10 @@ o.textContent = g.nome + ' (' + g.participantes + ')' + alerta; select.appendChild(o); }); - aviso.textContent = dados.grupos.length + ' grupo(s) encontrado(s).'; + avisar(dados.grupos.length + ' grupo(s) encontrado(s) — escolha um acima.', 'ok'); }) - .catch(function () { aviso.textContent = 'NĂŁo foi possĂ­vel falar com a ponte do WhatsApp.'; }) - .finally(function () { botao.disabled = false; botao.textContent = 'Buscar grupos'; }); + .catch(function () { avisar('NĂŁo foi possĂ­vel falar com a ponte do WhatsApp.', 'erro'); }) + .finally(function () { botao.disabled = false; botaoTexto.textContent = 'Buscar grupos'; }); }); })(); diff --git a/app/views/admin/contatos/edit.html.erb b/app/views/admin/contatos/edit.html.erb index a584faa..7698323 100644 --- a/app/views/admin/contatos/edit.html.erb +++ b/app/views/admin/contatos/edit.html.erb @@ -1,4 +1,8 @@ -
-

Editar contato

- <%= render "form", contato: @contato %> +
+
+

Editar: <%= @contato.nome %>

+

Atualize os dados deste contato

+
+ <%= render 'shared/flash' %> + <%= render 'form', contato: @contato %>
diff --git a/app/views/admin/contatos/index.html.erb b/app/views/admin/contatos/index.html.erb index 12fa0bb..e622273 100644 --- a/app/views/admin/contatos/index.html.erb +++ b/app/views/admin/contatos/index.html.erb @@ -1,85 +1,146 @@
+ + <%# Header — mesmo padrão de Usuários: título, subtítulo e ação primária %>
-

Contatos

+

+ <%= icone :usuarios, espaco: false %> Contatos +

Quem recebe as mensagens. Quem tem sĂł nĂșmero recebe sĂł WhatsApp; quem tem sĂł e-mail recebe sĂł e-mail.

- <%= link_to 'Novo contato', new_admin_contato_path(grupo_id: params[:grupo_id]), data: { turbo: false }, - class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %> + <%= link_to new_admin_contato_path(grupo_id: params[:grupo_id]), data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Novo contato + <% end %>
- - - +
+ - + + <% if params[:q].present? || params[:grupo_id].present? %> + <%= link_to 'Limpar', admin_contatos_path, data: { turbo: false }, + class: 'text-gray-500 hover:text-white text-sm transition-colors' %> + <% end %> + <%# Tabela %>
- <% if @contatos.empty? %> -

Nenhum contato encontrado.

- <% else %> -
- - - - - - - - - - - - - <% @contatos.each do |contato| %> - - - - - - + + + + + + <% end %> + + <% if @contatos.empty? %> + + + + <% end %> + +
NomeWhatsAppE-mailGrupoSituação
- <%= contato.nome %> - <% if contato.grupo_whatsapp? %> - grupo - <% end %> - - <% if contato.grupo_whatsapp? %> - <%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %> - <% else %> - <%= contato.telefone.presence || '—' %> - <% end %> - <%= contato.email.presence || '—' %> - <% if contato.grupo_contato %> - <%= contato.grupo_contato.nome %> - <% else %> - sem grupo - <% end %> - - - <%= contato.ativo? ? 'Ativo' : 'Inativo' %> +
+ + + + + + + + + + + + + <% @contatos.each do |contato| %> + + + - - - <% end %> - -
NomeWhatsAppE-mailGrupoSituaçãoAçÔes
+
+ <%# Avatar: inicial para pessoa, ícone para grupo — dá para + distinguir os dois tipos sem ler a linha inteira. %> +
+ <% if contato.grupo_whatsapp? %> + <%= icone :usuarios, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% else %> + <%= contato.nome.to_s[0].to_s.upcase %> + <% end %> +
+
+ <%= contato.nome %> + <% if contato.grupo_whatsapp? %> + grupo do WhatsApp + <% end %> +
+
+
+ <% if contato.grupo_whatsapp? %> + + <%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %> - - <%= link_to 'Editar', edit_admin_contato_path(contato), data: { turbo: false }, - class: 'text-gray-300 hover:text-orange-400 mr-3' %> - <%= button_to 'Excluir', admin_contato_path(contato), method: :delete, - form: { data: { turbo_confirm: "Excluir #{contato.nome}?" } }, - class: 'text-red-400 hover:text-red-300 inline' %> -
-
- <% end %> + <% else %> + <%= contato.telefone.presence || '—' %> + <% end %> +
<%= contato.email.presence || '—' %> + <% if contato.grupo_contato %> + <%= contato.grupo_contato.nome %> + <% else %> + + <%= icone :alerta, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false %> sem grupo + + <% end %> + <%= badge_status_usuario(contato.ativo?) %> +
+ <%= link_to edit_admin_contato_path(contato), data: { turbo: false }, + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + <%= icone :editar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <%= button_to admin_contato_path(contato), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + form: { data: { turbo_confirm: "Excluir #{contato.nome}?" } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> +
+
+
<%= icone :vazio, tamanho: 'w-10 h-10', cor: 'text-gray-600', espaco: false %>
+

Nenhum contato encontrado.

+
+
diff --git a/app/views/admin/contatos/new.html.erb b/app/views/admin/contatos/new.html.erb index a2f5fa8..9cf802c 100644 --- a/app/views/admin/contatos/new.html.erb +++ b/app/views/admin/contatos/new.html.erb @@ -1,4 +1,8 @@ -
-

Novo contato

- <%= render "form", contato: @contato %> +
+
+

Novo contato

+

Cadastre quem vai receber as mensagens

+
+ <%= render 'shared/flash' %> + <%= render 'form', contato: @contato %>
diff --git a/app/views/admin/eventos_notificacao/_form.html.erb b/app/views/admin/eventos_notificacao/_form.html.erb index 70fa856..7cca379 100644 --- a/app/views/admin/eventos_notificacao/_form.html.erb +++ b/app/views/admin/eventos_notificacao/_form.html.erb @@ -1,102 +1,172 @@ -<%= form_with model: [:admin, evento], url: (evento.persisted? ? admin_evento_path(evento) : admin_eventos_path), - data: { turbo: false }, class: 'space-y-5' do |f| %> +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + menor = 'w-full px-3 py-2.5 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + secao = 'text-sm font-medium text-gray-300 mb-4' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" +%> +<%= form_with model: [:admin, evento], + url: (evento.persisted? ? admin_evento_path(evento) : admin_eventos_path), + data: { turbo: false }, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' do |f| %> + <% if evento.errors.any? %> -
+
+

+ <%= pluralize(evento.errors.count, 'erro', 'erros') %> encontrado<%= evento.errors.count > 1 ? 's' : '' %>: +

    - <% evento.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% evento.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %> -
- <%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :nome, required: true, placeholder: 'Aviso de fechamento para a diretoria', - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> +
+
+ <%= f.label :nome, class: rotulo %> + <%= f.text_field :nome, required: true, + placeholder: 'Aviso de fechamento para a diretoria', class: input %> +
+ +
+ <%= f.label :gatilho, 'Gatilho (o que faz disparar)', class: rotulo %> + <% if evento.sistema? %> +

+ <%= icone :travado, cor: 'text-gray-500', tamanho: 'w-4 h-4', espaco: false %> + <%= evento.gatilho_label %> +

+

+ Evento de sistema: o gatilho e a chave sĂŁo fixos porque o cĂłdigo dispara por eles. +

+ <% else %> + <%= f.select :gatilho, + EventoNotificacao::GATILHOS.map { |g| [EventoNotificacao::GATILHO_LABEL[g], g] }, {}, + class: "#{input} cursor-pointer" %> + <% end %> +
+ +
+ <%= f.label :descricao, 'Descrição', class: rotulo %> + <%= f.text_field :descricao, placeholder: 'Opcional', class: input %> +
-
- <%= f.label :gatilho, 'Gatilho (o que faz disparar)', class: 'block text-gray-400 text-sm mb-1' %> - <% if evento.sistema? %> -

<%= evento.gatilho_label %>

-

Evento de sistema: o gatilho e a chave sĂŁo fixos porque o cĂłdigo dispara por eles.

- <% else %> - <%= f.select :gatilho, EventoNotificacao::GATILHOS.map { |g| [EventoNotificacao::GATILHO_LABEL[g], g] }, {}, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> - <% end %> -
- -
- <%= f.label :descricao, 'Descrição', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :descricao, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> -
- - <%# Só faz sentido no gatilho `agendado`; deixo sempre visível para não - depender de JS, com a explicação de quando vale. %> -
- Agendamento (sĂł vale no gatilho "Agendado") -
+ <%# Agendamento — só faz sentido no gatilho `agendado`; deixo sempre visível + para não depender de JS, com a explicação de quando vale. %> +
+

+ Agendamento + — só vale no gatilho "Agendado" +

+
- <%= f.label :frequencia, 'FrequĂȘncia', class: 'block text-gray-500 text-xs mb-1' %> - <%= f.select :frequencia, EventoNotificacao::FREQUENCIAS.map { |x| [x.capitalize, x] }, { include_blank: '—' }, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %> + <%= f.label :frequencia, 'FrequĂȘncia', class: rotulo %> + <%= f.select :frequencia, EventoNotificacao::FREQUENCIAS.map { |x| [x.capitalize, x] }, + { include_blank: '—' }, class: "#{menor} cursor-pointer" %>
- <%= f.label :hora, 'Hora', class: 'block text-gray-500 text-xs mb-1' %> - <%= f.number_field :hora, min: 0, max: 23, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %> + <%= f.label :hora, 'Hora', class: rotulo %> + <%= f.number_field :hora, min: 0, max: 23, class: menor %>
- <%= f.label :dia_semana, 'Dia (semanal)', class: 'block text-gray-500 text-xs mb-1' %> - <%= f.select :dia_semana, Date::DAYNAMES.each_with_index.map { |nome, i| [nome, i] }, { include_blank: '—' }, - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %> + <%= f.label :dia_semana, 'Dia (semanal)', class: rotulo %> + <%= f.select :dia_semana, Date::DAYNAMES.each_with_index.map { |nome, i| [nome, i] }, + { include_blank: '—' }, class: "#{menor} cursor-pointer" %>
-
- - - - +
<%# Assinatura por grupo: TODOS os grupos ativos aparecem, marcados os que jĂĄ assinam. Sem isso a tela sĂł mostraria os jĂĄ configurados e nĂŁo haveria por onde adicionar. %> -
- Quais grupos recebem +
+

Quais grupos recebem

+ <% if @linhas_assinatura.blank? %> -

- Nenhum grupo cadastrado ainda — - <%= link_to 'crie um grupo', new_admin_grupo_path, data: { turbo: false }, class: 'text-orange-400 underline' %> - antes. -

- <% end %> - <% Array(@linhas_assinatura).each do |linha| %> - <% grupo = linha[:grupo]; assinatura = linha[:assinatura] %> -
- - <%= select_tag "assinaturas[#{grupo.id}][canal]", - options_for_select(GrupoEventoAssinatura::CANAIS.map { |c| [GrupoEventoAssinatura::CANAL_LABEL[c], c] }, assinatura&.canal || 'ambos'), - class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2' %> +
+
<%= icone :vazio, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
+

+ Nenhum grupo cadastrado ainda — + <%= link_to 'crie um grupo', new_admin_grupo_path, data: { turbo: false }, + class: 'text-[#f97316] hover:text-orange-400 underline' %> + antes. +

+
+ <% else %> +
+ <% Array(@linhas_assinatura).each do |linha| %> + <% grupo = linha[:grupo]; assinatura = linha[:assinatura] %> +
+ + <%= select_tag "assinaturas[#{grupo.id}][canal]", + options_for_select( + GrupoEventoAssinatura::CANAIS.map { |c| [GrupoEventoAssinatura::CANAL_LABEL[c], c] }, + assinatura&.canal || 'ambos' + ), + class: 'px-3 py-2 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] + transition-colors cursor-pointer' %> +
+ <% end %>
<% end %> -
+
-
- <%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> - <%= link_to 'Cancelar', admin_eventos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %> + <%# OpçÔes %> +
+
+
+ <%= f.label :notificar_envolvido, 'Avisar também a pessoa envolvida no fato', + class: 'block text-sm font-medium text-gray-300' %> +

+ Ex.: o motorista dono do pagamento, além dos grupos. Só se aplica aos gatilhos de + consolidação/pagamento/valor. +

+
+ +
+ +
+
+ <%= f.label :ativo, 'Evento ativo', class: 'block text-sm font-medium text-gray-300' %> +

Evento inativo nĂŁo dispara nada

+
+ +
+
+ +
+ <%= link_to 'Cancelar', admin_eventos_path, data: { turbo: false }, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit evento.new_record? ? 'Criar Evento' : 'Salvar AlteraçÔes', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<% end %> diff --git a/app/views/admin/eventos_notificacao/edit.html.erb b/app/views/admin/eventos_notificacao/edit.html.erb index c77367a..1af5e2b 100644 --- a/app/views/admin/eventos_notificacao/edit.html.erb +++ b/app/views/admin/eventos_notificacao/edit.html.erb @@ -1,4 +1,8 @@ -
-

Editar evento

- <%= render "form", evento: @evento %> +
+
+

Editar: <%= @evento.nome %>

+

Atualize o gatilho, o agendamento e os grupos assinantes

+
+ <%= render 'shared/flash' %> + <%= render 'form', evento: @evento %>
diff --git a/app/views/admin/eventos_notificacao/index.html.erb b/app/views/admin/eventos_notificacao/index.html.erb index 962cd2e..ac20768 100644 --- a/app/views/admin/eventos_notificacao/index.html.erb +++ b/app/views/admin/eventos_notificacao/index.html.erb @@ -1,90 +1,157 @@ +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors text-sm' + pill = 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold' +%>
+
-

Eventos de notificação

+

+ <%= icone :diario, espaco: false %> Eventos de notificação +

O que dispara mensagem e para quais grupos. VocĂȘ cria quantos eventos quiser — o gatilho sai de uma lista fixa, porque gatilho Ă© cĂłdigo.

- <%= link_to 'Novo evento', new_admin_evento_path, data: { turbo: false }, - class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %> + <%= link_to new_admin_evento_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Novo evento + <% end %>
-
+ <%= render 'shared/flash' %> + +
<% @eventos.each do |evento| %> -
-
-
-

- <%= evento.nome %> - <%= evento.gatilho_label %> + <%# Evento desativado fica esmaecido: a lista mistura ativo e inativo e sem + isso os dois pesam igual na varredura visual. %> +

+
+
+
+

<%= evento.nome %>

+ + <%= evento.gatilho_label %> + <% if evento.sistema? %> - sistema + sistema <% end %> <% unless evento.ativo? %> - desativado + desativado <% end %> -

-

<%= evento.descricao.presence || '—' %>

-

+

+ +

<%= evento.descricao.presence || '—' %>

+ +
<% assinantes = evento.assinaturas.select(&:ativo) %> <% if assinantes.any? %> - Grupos: <%= assinantes.map { |a| "#{a.grupo_contato.nome} (#{a.canal_label})" }.join(' · ') %> +
+ Grupos + <% assinantes.each do |a| %> + + <%= a.grupo_contato.nome %> + · <%= a.canal_label %> + + <% end %> +
<% else %> - Nenhum grupo assina — este evento não manda nada. +

+ <%= icone :alerta, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false %> + Nenhum grupo assina — este evento não manda nada. +

<% end %> + <% if evento.agendado? %> - · <%= evento.frequencia == 'semanal' ? "toda #{Date::DAYNAMES[evento.dia_semana.to_i]}" : 'todo dia' %> às <%= format('%02d:00', evento.hora.to_i) %> +

+ <%= icone :relogio, cor: 'text-gray-500', tamanho: 'w-3.5 h-3.5', espaco: false %> + <%= evento.frequencia == 'semanal' ? "Toda #{Date::DAYNAMES[evento.dia_semana.to_i]}" : 'Todo dia' %> + Ă s <%= format('%02d:00', evento.hora.to_i) %> +

<% end %> -

+
-
- <%# Editor de blocos, um por canal. O ✓ diz se jĂĄ existe mensagem - montada — sem ela o evento usa o texto padrĂŁo do sistema. %> - <% %w[whatsapp email].each do |canal| %> - <% montado = evento.template_utilizavel(canal).present? %> - <%= link_to "#{montado ? '✓ ' : ''}#{canal == 'email' ? 'E-mail' : 'WhatsApp'}", - template_admin_evento_path(evento, canal: canal), data: { turbo: false }, - title: montado ? 'Mensagem montada no editor' : 'Sem mensagem — usa o texto padrĂŁo', - class: "text-sm #{montado ? 'text-green-400 hover:text-green-300' : 'text-gray-400 hover:text-orange-400'}" %> - <% end %> - | - <%= link_to 'Editar', edit_admin_evento_path(evento), data: { turbo: false }, - class: 'text-gray-300 hover:text-orange-400 text-sm' %> - <% if evento.apagavel? %> - <%= button_to 'Excluir', admin_evento_path(evento), method: :delete, - form: { data: { turbo_confirm: "Excluir o evento #{evento.nome}?" } }, - class: 'text-red-400 hover:text-red-300 text-sm inline' %> - <% end %> + + <%# AçÔes: editor de mensagem por canal + editar/excluir %> +
+
+ <%# O ✓ diz se já existe mensagem montada — sem ela o evento usa o + texto padrão do sistema. %> + <% %w[whatsapp email].each do |canal| %> + <% montado = evento.template_utilizavel(canal).present? %> + <%= link_to template_admin_evento_path(evento, canal: canal), data: { turbo: false }, + title: montado ? 'Mensagem montada no editor' : 'Sem mensagem — usa o texto padrão', + class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border transition-colors #{ + montado ? 'bg-green-500/10 border-green-500/30 text-green-300 hover:border-green-500' + : 'bg-[#0a0a0a] border-white/10 text-gray-400 hover:border-[#f97316] hover:text-white'}" do %> + <%= icone(montado ? :sucesso : (canal == 'email' ? :email : :telefone), + cor: nil, tamanho: 'w-4 h-4', espaco: false) %> + <%= canal == 'email' ? 'E-mail' : 'WhatsApp' %> + <% end %> + <% end %> +
+ +
+ <%= link_to edit_admin_evento_path(evento), data: { turbo: false }, + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + <%= icone :editar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <% if evento.apagavel? %> + <%= button_to admin_evento_path(evento), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + form: { data: { turbo_confirm: "Excluir o evento #{evento.nome}?" } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <% end %> +
- <%# Disparo manual: manda mensagem DE VERDADE. O texto Ă© digitado aqui - porque o editor de blocos ainda nĂŁo existe. %> + <%# Disparo manual: manda mensagem DE VERDADE. %> <% if evento.manual? && evento.ativo? %> -
- Disparar agora +
+ + <%= icone :expandir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Disparar agora + <% com_template = %w[whatsapp email].any? { |c| evento.template_utilizavel(c) } %> <%= form_with url: disparar_admin_evento_path(evento), method: :post, - data: { turbo: false }, class: 'mt-3 space-y-3' do |f| %> + data: { turbo: false }, class: 'mt-4 space-y-3' do |f| %> <% if com_template %> -

- Este evento tem mensagem montada no editor — ela será usada nos canais configurados. - O texto abaixo só vale para o canal que ainda não tem mensagem própria. +

+ <%= icone :sucesso, cor: nil, tamanho: 'w-4 h-4', espaco: false, classe: 'mt-0.5' %> + + Este evento tem mensagem montada no editor — ela será usada nos canais configurados. + O texto abaixo só vale para o canal que ainda não tem mensagem própria. +

<% end %> - <%= f.text_field :assunto, placeholder: 'Assunto (só e-mail)', value: evento.nome, - class: 'w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> - <%= f.text_area :corpo, rows: 4, required: !com_template, placeholder: com_template ? 'Opcional — só para o canal sem mensagem montada' : 'Mensagem que será enviada
', - class: 'w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> + <%= f.text_field :assunto, placeholder: 'Assunto (só e-mail)', value: evento.nome, class: input %> + <%= f.text_area :corpo, rows: 4, required: !com_template, class: input, + placeholder: com_template ? 'Opcional — só para o canal sem mensagem montada' : 'Mensagem que será enviada
' %> <%= f.submit 'Enviar para os grupos assinantes', - data: { turbo_confirm: 'Isso envia mensagem de verdade para todos os contatos dos grupos assinantes. Confirmar?' }, - class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> + data: { turbo_confirm: 'Isso envia mensagem de verdade para todos os contatos dos grupos assinantes. Confirmar?' }, + class: 'px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> <% end %>
<% end %>
<% end %> + + <% if @eventos.empty? %> +
+
<%= icone :vazio, tamanho: 'w-10 h-10', cor: 'text-gray-600', espaco: false %>
+

Nenhum evento cadastrado.

+
+ <% end %>
diff --git a/app/views/admin/eventos_notificacao/new.html.erb b/app/views/admin/eventos_notificacao/new.html.erb index e5353cf..9f2cc8f 100644 --- a/app/views/admin/eventos_notificacao/new.html.erb +++ b/app/views/admin/eventos_notificacao/new.html.erb @@ -1,4 +1,8 @@ -
-

Novo evento

- <%= render "form", evento: @evento %> +
+
+

Novo evento

+

Defina o que dispara a mensagem e quem recebe

+
+ <%= render 'shared/flash' %> + <%= render 'form', evento: @evento %>
diff --git a/app/views/admin/grupos_contato/_form.html.erb b/app/views/admin/grupos_contato/_form.html.erb index fb20ded..f8ecbd4 100644 --- a/app/views/admin/grupos_contato/_form.html.erb +++ b/app/views/admin/grupos_contato/_form.html.erb @@ -1,36 +1,63 @@ +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" +%> <%# URL explĂ­cita: a rota se chama `grupo`/`grupos` (ver routes.rb), entĂŁo a rota polimĂłrfica de `model:` procuraria admin_grupo_contato_path e estouraria. O `model:` continua valendo para nomear os campos (grupo_contato[...]). %> <%= form_with model: [:admin, grupo], url: (grupo.persisted? ? admin_grupo_path(grupo) : admin_grupos_path), - data: { turbo: false }, class: 'space-y-5' do |f| %> + data: { turbo: false }, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' do |f| %> + <% if grupo.errors.any? %> -
+
+

+ <%= pluralize(grupo.errors.count, 'erro', 'erros') %> encontrado<%= grupo.errors.count > 1 ? 's' : '' %>: +

    - <% grupo.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% grupo.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %>
- <%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :nome, required: true, placeholder: 'Diretoria', - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> + <%= f.label :nome, class: rotulo %> + <%= f.text_field :nome, required: true, placeholder: 'Diretoria', class: input %>
- <%= f.label :descricao, 'Descrição', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.text_field :descricao, placeholder: 'Quem acompanha o fechamento', - class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> + <%= f.label :descricao, 'Descrição', class: rotulo %> + <%= f.text_field :descricao, placeholder: 'Quem acompanha o fechamento', class: input %> +

Opcional — ajuda a lembrar para que serve o grupo.

- +
+
+ <%= f.label :ativo, 'Grupo ativo', class: 'block text-sm font-medium text-gray-300' %> +

Grupo inativo nĂŁo recebe nada, mesmo assinando eventos

+
+ +
-
- <%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> - <%= link_to 'Cancelar', admin_grupos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %> +
+ <%= link_to 'Cancelar', admin_grupos_path, data: { turbo: false }, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit grupo.new_record? ? 'Criar Grupo' : 'Salvar AlteraçÔes', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<% end %> diff --git a/app/views/admin/grupos_contato/edit.html.erb b/app/views/admin/grupos_contato/edit.html.erb index 0c729a8..1942319 100644 --- a/app/views/admin/grupos_contato/edit.html.erb +++ b/app/views/admin/grupos_contato/edit.html.erb @@ -1,4 +1,8 @@ -
-

Editar grupo

+
+
+

Editar: <%= @grupo.nome %>

+

Atualize as informaçÔes do grupo

+
+ <%= render 'shared/flash' %> <%= render 'form', grupo: @grupo %>
diff --git a/app/views/admin/grupos_contato/index.html.erb b/app/views/admin/grupos_contato/index.html.erb index 13c7c28..646f108 100644 --- a/app/views/admin/grupos_contato/index.html.erb +++ b/app/views/admin/grupos_contato/index.html.erb @@ -1,65 +1,105 @@
+
-

Grupos de contato

+

+ <%= icone :tag, espaco: false %> Grupos de contato +

É o grupo que assina os eventos — cadastrar alguĂ©m novo Ă© escolher o grupo, nĂŁo repetir a configuração pessoa por pessoa.

- <%= link_to 'Novo grupo', new_admin_grupo_path, data: { turbo: false }, - class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %> -
- -
- <% if @grupos.empty? %> -

- Nenhum grupo ainda. Crie um (ex.: Diretoria, - Motoristas) e depois cadastre os contatos dentro dele. -

- <% else %> -
- - - - - - - - - - - - - <% @grupos.each do |grupo| %> - - - - - - - - - <% end %> - -
GrupoDescriçãoContatosAssina os eventosSituação
<%= grupo.nome %><%= grupo.descricao.presence || '—' %> - <%= link_to grupo.contatos.size, admin_contatos_path(grupo_id: grupo.id), - data: { turbo: false }, class: 'text-orange-400 hover:text-orange-300 underline' %> - - <% nomes = grupo.assinaturas.select(&:ativo).map { |a| a.evento_notificacao.nome } %> - <%= nomes.any? ? nomes.join(', ') : '— nenhum' %> - - - <%= grupo.ativo? ? 'Ativo' : 'Inativo' %> - - - <%= link_to 'Editar', edit_admin_grupo_path(grupo), data: { turbo: false }, - class: 'text-gray-300 hover:text-orange-400 mr-3' %> - <%= button_to 'Excluir', admin_grupo_path(grupo), method: :delete, - form: { data: { turbo_confirm: "Excluir #{grupo.nome}? Os #{grupo.contatos.size} contato(s) ficam sem grupo e param de receber." } }, - class: 'text-red-400 hover:text-red-300 inline' %> -
-
+ <%= link_to new_admin_grupo_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Novo grupo <% end %>
+ + <%= render 'shared/flash' %> + +
+
+ + + + + + + + + + + + + <% @grupos.each do |grupo| %> + + + + + + + + + <% end %> + + <% if @grupos.empty? %> + + + + <% end %> + +
GrupoDescriçãoContatosAssina os eventosSituaçãoAçÔes
+
+
+ <%= icone :tag, cor: nil, tamanho: 'w-4 h-4', espaco: false %> +
+ <%= grupo.nome %> +
+
<%= grupo.descricao.presence || '—' %> + <%= link_to admin_contatos_path(grupo_id: grupo.id), data: { turbo: false }, + class: 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold + bg-[#f97316]/15 border border-[#f97316]/30 text-[#f97316] + hover:bg-[#f97316]/25 transition-colors', + title: 'Ver os contatos deste grupo' do %> + <%= grupo.contatos.size %> + <% end %> + + <% nomes = grupo.assinaturas.select(&:ativo).map { |a| a.evento_notificacao.nome } %> + <% if nomes.any? %> +
+ <% nomes.each do |nome| %> + <%= nome %> + <% end %> +
+ <% else %> + — nenhum + <% end %> +
<%= badge_status_usuario(grupo.ativo?) %> +
+ <%= link_to edit_admin_grupo_path(grupo), data: { turbo: false }, + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + <%= icone :editar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <%= button_to admin_grupo_path(grupo), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + form: { data: { turbo_confirm: "Excluir #{grupo.nome}? Os #{grupo.contatos.size} contato(s) ficam sem grupo e param de receber." } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> +
+
+
<%= icone :vazio, tamanho: 'w-10 h-10', cor: 'text-gray-600', espaco: false %>
+

Nenhum grupo ainda.

+

+ Crie um (ex.: Diretoria, + Motoristas) e depois cadastre os contatos dentro dele. +

+
+
+
diff --git a/app/views/admin/grupos_contato/new.html.erb b/app/views/admin/grupos_contato/new.html.erb index 19714bb..aba1218 100644 --- a/app/views/admin/grupos_contato/new.html.erb +++ b/app/views/admin/grupos_contato/new.html.erb @@ -1,4 +1,8 @@ -
-

Novo grupo de contato

+
+
+

Novo grupo de contato

+

Junte contatos que recebem os mesmos eventos

+
+ <%= render 'shared/flash' %> <%= render 'form', grupo: @grupo %>
diff --git a/app/views/admin/mensagem_templates/edit.html.erb b/app/views/admin/mensagem_templates/edit.html.erb index e5572ac..a6bf97a 100644 --- a/app/views/admin/mensagem_templates/edit.html.erb +++ b/app/views/admin/mensagem_templates/edit.html.erb @@ -6,27 +6,53 @@
-
-
-

Mensagem — <%= @evento.nome %>

-

- Canal: <%= @canal == 'email' ? 'E-mail' : 'WhatsApp' %> - · Gatilho: <%= @evento.gatilho_label %> -

+
+
+

+ <%= icone :editar, espaco: false %> Mensagem — <%= @evento.nome %> +

+
+ <% wpp = @canal != 'email' %> + + <%= icone(wpp ? :telefone : :email, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false) %> + <%= wpp ? 'WhatsApp' : 'E-mail' %> + + + <%= @evento.gatilho_label %> + +
-
- <%= link_to "Editar #{outro_canal == 'email' ? 'e-mail' : 'WhatsApp'}", - template_admin_evento_path(@evento, canal: outro_canal), data: { turbo: false }, - class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %> - <%= link_to 'Voltar', admin_eventos_path, data: { turbo: false }, - class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %> +
+ <%= link_to template_admin_evento_path(@evento, canal: outro_canal), data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm border + border-white/10 text-gray-300 hover:border-white/20 hover:text-white + transition-colors whitespace-nowrap' do %> + <%= icone(outro_canal == 'email' ? :email : :telefone, cor: nil, tamanho: 'w-4 h-4', espaco: false) %> + Editar <%= outro_canal == 'email' ? 'e-mail' : 'WhatsApp' %> + <% end %> + <%= link_to admin_eventos_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm border + border-white/10 text-gray-300 hover:border-white/20 hover:text-white + transition-colors whitespace-nowrap' do %> + <%= icone :voltar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Voltar + <% end %>
+ <%= render 'shared/flash' %> + <% if @template.errors.any? %> -
+
+

+ <%= pluralize(@template.errors.count, 'erro', 'erros') %> encontrado<%= @template.errors.count > 1 ? 's' : '' %>: +

    - <% @template.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% @template.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %> @@ -40,11 +66,12 @@ <%# ── Paleta ──────────────────────────────────────────── %>
-

Blocos

+

Blocos

Arraste para a mensagem — ou clique.

<% Notificacao::Blocos::CATALOGO.each do |tipo, meta| %> -
⠿ <%= meta[:rotulo] %> @@ -56,14 +83,17 @@ <%# ── Variáveis ─────────────────────────────────────── %>
-

VariĂĄveis

+

VariĂĄveis

Clique num campo da mensagem e depois numa variĂĄvel para inseri-la ali.

<% @variaveis.each do |nome, meta| %> - <% end %> @@ -75,34 +105,47 @@
<% if @canal == 'email' %>
- - <%= text_field_tag :assunto, @template.assunto, id: 'assunto', class: 'campo-editavel w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> + + <%= text_field_tag :assunto, @template.assunto, id: 'assunto', + class: 'campo-editavel w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl + text-white placeholder-gray-600 focus:outline-none focus:border-[#f97316] + focus:ring-1 focus:ring-[#f97316] transition-colors text-sm' %>
<% end %>
-

Mensagem

+

Mensagem

-

- Arraste um bloco para cĂĄ. Sem blocos, o evento continua usando o texto padrĂŁo do sistema. -

+
+
<%= icone :vazio, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
+

+ Arraste um bloco para cĂĄ. Sem blocos, o evento continua usando o texto padrĂŁo do sistema. +

+
-
- <%= submit_tag 'Salvar mensagem', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> -
<%# ── Preview ─────────────────────────────────────────── %>
-

Preview

+

Preview

Com valores de exemplo, renderizado pelo mesmo cĂłdigo do envio.

@@ -157,12 +200,12 @@ if (especie === 'multilinha') { return '' + ''; } return '' + ''; + ' class="campo-editavel campo-bloco w-full bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-[#f97316]">'; } function listaHtml(indice, linhas) { @@ -170,9 +213,9 @@ (linhas || []).forEach(function (linha, j) { html += '
' + '' + + ' class="campo-editavel campo-linha flex-1 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-[#f97316]">' + '' + + ' class="campo-editavel campo-linha flex-1 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-[#f97316]">' + '' + '
'; }); @@ -187,7 +230,7 @@ blocos.forEach(function (bloco, i) { var meta = CATALOGO[bloco.tipo] || { rotulo: bloco.tipo, campos: {} }; var card = document.createElement('div'); - card.className = 'bg-[#141414] border border-white/10 rounded-xl p-3'; + card.className = 'bg-[#0a0a0a] border border-white/10 rounded-xl p-3 hover:border-white/20 transition-colors'; card.dataset.indice = i; var corpo = '
' + diff --git a/app/views/admin/notificacao_envios/index.html.erb b/app/views/admin/notificacao_envios/index.html.erb index 710a37e..a027a7e 100644 --- a/app/views/admin/notificacao_envios/index.html.erb +++ b/app/views/admin/notificacao_envios/index.html.erb @@ -1,95 +1,142 @@ <% ctx = { status: params[:status], canal: params[:canal] }.compact_blank %> +<% + # Pílula de filtro: uma só definição para os dois grupos (situação e canal), + # senão o estilo do "ativo" fatalmente diverge entre eles com o tempo. + pilula = lambda do |ativo| + base = 'px-4 py-2 rounded-xl text-sm border transition-colors' + ativo ? "#{base} bg-[#f97316] text-white border-[#f97316] font-semibold" + : "#{base} bg-[#0a0a0a] text-gray-400 border-white/10 hover:border-white/20 hover:text-white" + end +%>
+
-

Envios

+

+ <%= icone :historico, espaco: false %> Envios +

O que saiu, para quem e se chegou. Uma falha de envio nunca trava o fechamento — entĂŁo Ă© aqui que ela aparece.

-
- <% [['', 'Todos'], ['enviado', 'Enviados'], ['falhou', 'Falhados'], ['pendente', 'Pendentes']].each do |valor, rotulo| %> - <% ativo = params[:status].to_s == valor %> - <%= link_to rotulo, admin_envios_path(ctx.merge(status: valor.presence).compact), data: { turbo: false }, - class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> - <% end %> - | - <% [['', 'Todos os canais'], ['whatsapp', 'WhatsApp'], ['email', 'E-mail']].each do |valor, rotulo| %> - <% ativo = params[:canal].to_s == valor %> - <%= link_to rotulo, admin_envios_path(ctx.merge(canal: valor.presence).compact), data: { turbo: false }, - class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> - <% end %> + <%= render 'shared/flash' %> + + <%# Filtros %> +
+
+ Situação + <% [['', 'Todos'], ['enviado', 'Enviados'], ['falhou', 'Falhados'], ['pendente', 'Pendentes']].each do |valor, rotulo| %> + <%= link_to rotulo, admin_envios_path(ctx.merge(status: valor.presence).compact), + data: { turbo: false }, class: pilula.call(params[:status].to_s == valor) %> + <% end %> +
+ + + +
+ Canal + <% [['', 'Todos'], ['whatsapp', 'WhatsApp'], ['email', 'E-mail']].each do |valor, rotulo| %> + <%= link_to rotulo, admin_envios_path(ctx.merge(canal: valor.presence).compact), + data: { turbo: false }, class: pilula.call(params[:canal].to_s == valor) %> + <% end %> +
+ <% if @total_falhas.positive? %> - <%= @total_falhas %> falha(s) neste filtro + + <%= icone :erro, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false %> + <%= @total_falhas %> falha(s) neste filtro + <% end %>
+ <%# Tabela %>
- <% if @envios.empty? %> -

Nenhum envio registrado ainda.

- <% else %> -
- - - - - - - - - +
+
QuandoEventoDestinatårioCanalSituaçãoMensagem
+ + + + + + + + + + + + <% @envios.each do |envio| %> + + + + + + + - - - <% @envios.each do |envio| %> - - - - - - - - - <% end %> - -
QuandoEventoDestinatårioCanalSituaçãoMensagem
+ <%= envio.created_at.strftime('%d/%m/%Y') %> + <%= envio.created_at.strftime('%H:%M') %> + <%= envio.evento_notificacao&.nome || '—' %> + <%= envio.contato&.nome || envio.user&.nome || '—' %> + <%= envio.destino %> + + <% wpp = envio.canal == 'whatsapp' %> + + <%= icone(wpp ? :telefone : :email, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false) %> + <%= wpp ? 'WhatsApp' : 'E-mail' %> + + + <% cfg = case envio.status + when 'enviado' then { cor: 'bg-green-600 text-white', icone: :sucesso, label: 'Enviado' } + when 'falhou' then { cor: 'bg-red-600 text-white', icone: :erro, label: 'Falhou' } + else { cor: 'bg-yellow-500 text-black', icone: :relogio, label: envio.status.to_s.capitalize } + end %> + <%= badge_com_icone(cfg) %> + <% if envio.falhou? && envio.erro.present? %> + <%= envio.erro.truncate(160) %> + <% end %> + + <%= envio.corpo.to_s.truncate(90) %> +
<%= envio.created_at.strftime('%d/%m/%Y %H:%M') %><%= envio.evento_notificacao&.nome || '—' %> - <%= envio.contato&.nome || envio.user&.nome || '—' %> - <%= envio.destino %> - <%= envio.canal == 'whatsapp' ? 'WhatsApp' : 'E-mail' %> - <% cor = { 'enviado' => 'text-green-400', 'falhou' => 'text-red-400' }.fetch(envio.status, 'text-yellow-400') %> - <%= envio.status.capitalize %> - <% if envio.falhou? && envio.erro.present? %> - <%= envio.erro.truncate(160) %> - <% end %> - - <%= envio.corpo.to_s.truncate(90) %> -
-
+ <% end %> - <% if @pagy.pages > 1 %> - <% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %> -
+ + <% if @envios.any? && @pagy.pages > 1 %> + <% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %> + - <% end %> + <% end %> + <% if @pagy.next %> + <%= link_to 'â€ș', pag.call(@pagy.next), data: { turbo: false }, + class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-white/10 text-white hover:border-[#f97316] transition-colors' %> + <% end %> + <% end %>
diff --git a/app/views/admin/whatsapp_sessoes/show.html.erb b/app/views/admin/whatsapp_sessoes/show.html.erb index bd56113..76e2a8b 100644 --- a/app/views/admin/whatsapp_sessoes/show.html.erb +++ b/app/views/admin/whatsapp_sessoes/show.html.erb @@ -1,86 +1,117 @@ -
+
+
-

WhatsApp — conexão

+

+ <%= icone :telefone, espaco: false %> WhatsApp — conexão +

Pareia o nĂșmero da empresa com este servidor lendo um QR code, como no WhatsApp Web.

+ <%= render 'shared/flash' %> + <%# O aviso fica na TELA, nĂŁo sĂł no cĂłdigo: quem parear precisa saber o que estĂĄ aceitando. %> -
-

⚠ Este canal nĂŁo Ă© oficial.

-

+

+

+ <%= icone :alerta, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Este canal nĂŁo Ă© oficial. +

+

A conexĂŁo por QR usa a porta do WhatsApp Web por engenharia reversa. É gratuita e ilimitada, mas estĂĄ fora dos Termos do WhatsApp — a Meta pode banir o nĂșmero sem aviso. Use um chip dedicado, nunca o nĂșmero principal da operação.

-

+

Disparo em rajada Ă© o que mais causa banimento. O intervalo entre mensagens fica em <%= link_to 'ConfiguraçÔes → NotificaçÔes', admin_configuracao_notificacao_path, - data: { turbo: false }, class: 'underline hover:text-white' %> + data: { turbo: false }, class: 'underline hover:text-white transition-colors' %> (hoje: <%= @config.intervalo_envio %>s).

-
+
<% if @estado['conectado'] %> -
- +
+ <%# Ponto pulsante: Ă  distĂąncia, Ă© o que diz "estĂĄ no ar" sem ler o texto. %> + + + +
-

Conectado

-

+

Conectado

+

NĂșmero <%= @estado['numero'] %> - <% if @config.whatsapp_conectado_em %> · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %><% end %> + <% if @config.whatsapp_conectado_em %> + · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %> + <% end %>

-
+
<%= form_with url: testar_admin_whatsapp_sessao_path, method: :post, data: { turbo: false }, class: 'flex flex-wrap items-end gap-3' do |f| %> -
- <%= f.label :numero, 'Enviar teste para', class: 'block text-gray-400 text-sm mb-1' %> +
+ <%= f.label :numero, 'Enviar teste para', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> <%= f.text_field :numero, placeholder: '(11) 92005-1157', - class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' %>
<%= f.submit 'Enviar teste', - class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> + class: 'px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> <% end %> - <%= button_to 'Desconectar', desconectar_admin_whatsapp_sessao_path, method: :delete, - form: { data: { turbo_confirm: 'Isso encerra a sessĂŁo e para TODOS os envios de WhatsApp atĂ© vocĂȘ ler o QR de novo. Confirmar?' } }, - class: 'text-red-400 hover:text-red-300 text-sm' %> +
+ <%= button_to desconectar_admin_whatsapp_sessao_path, method: :delete, + form: { data: { turbo_confirm: 'Isso encerra a sessĂŁo e para TODOS os envios de WhatsApp atĂ© vocĂȘ ler o QR de novo. Confirmar?' } }, + class: 'inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm border + border-red-500/30 text-red-400 hover:bg-red-900/20 hover:border-red-500/50 + transition-colors cursor-pointer' do %> + <%= icone :sair, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Desconectar + <% end %> +
<% elsif @estado['qr'].present? %>
-

Leia o QR code

-

- No celular do chip dedicado: WhatsApp → Aparelhos conectados → - Conectar um aparelho → aponte para o código abaixo. + + Aguardando leitura + +

Leia o QR code

+

+ No celular do chip dedicado: WhatsApp → Aparelhos conectados → + Conectar um aparelho → aponte para o código abaixo.

<%# O QR expira em ~20s e a ponte gera outro — o polling abaixo troca a imagem sozinho, senão o operador ficaria lendo um código morto. %> QR code do WhatsApp + class="rounded-2xl bg-white p-3 shadow-lg" id="qr-whatsapp">

O cĂłdigo se renova sozinho a cada poucos segundos.

<% else %> -
- -
-

Desconectado

-

+

+ +
+

Desconectado

+

<%= @estado['erro'].presence || @estado['ultimo_erro'].presence || 'A ponte ainda nĂŁo gerou um QR code. Aguarde alguns segundos e recarregue.' %>

<% unless @cliente.configurado? %> -

- Falta configurar a ponte: defina WHATSAPP_TOKEN no .env - (o mesmo valor do container whatsapp) e suba os containers. -

+
+

+ Falta configurar a ponte: defina + WHATSAPP_TOKEN no + .env (o mesmo valor do container + whatsapp) e suba os containers. +

+
<% end %>
-- 2.49.1 From e272b96d0cb8877f07135457d9d2bebe8ecfe5f4d93c69127b68a366fc922206 Mon Sep 17 00:00:00 2001 From: victor Date: Tue, 25 Aug 2026 01:06:19 -0300 Subject: [PATCH 10/40] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20do=20READ.me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/README.md b/README.md index 42f9714..73657f4 100644 --- a/README.md +++ b/README.md @@ -2770,3 +2770,178 @@ docker compose exec app bin/rails db:migrate ``` + +--- + +
+🎹 Ponte desatualizada, nome do Baileys fora da tela e padronização visual (25/08/2026) + +> ⚠ **STATUS: implementado, nĂŁo executado.** Sem migration. Sintaxe conferida nos 16 `.erb` +> tocados e nos 2 `.rb`. **Testes nĂŁo rodados** — nĂŁo hĂĄ bundler na mĂĄquina de desenvolvimento, +> sĂł no container. + +### 1. `rota desconhecida` ao buscar os grupos + +O botĂŁo "Buscar grupos" do cadastro de contato respondia **`rota desconhecida`**. NĂŁo era bug do +Rails: Ă© o 404 da prĂłpria ponte (`whatsapp/server.js`, fallback de rota inexistente) repassado cru +para a tela. + +A rota `GET /grupos` existe no cĂłdigo desde o commit `568f185`, mas **o container `whatsapp` do +ambiente de teste rodava uma imagem construĂ­da antes dele**. O deploy subiu o `app` e reaproveitou +a imagem em cache da ponte. + +**Correção no servidor:** +```bash +docker compose up -d --build whatsapp +# se o cache ainda entregar a imagem velha: +docker compose build --no-cache whatsapp && docker compose up -d whatsapp +``` +O volume `whatsapp_auth` Ă© preservado — **nĂŁo precisa ler o QR de novo**. + +> 🔁 **A armadilha que volta:** toda vez que `whatsapp/server.js` ganha rota nova, a ponte precisa +> de rebuild prĂłprio. `docker compose up -d --build` sem nomear o serviço pode reaproveitar a +> camada em cache, e o sintoma Ă© sempre este: a tela nova chama uma rota que a ponte em pĂ© nĂŁo +> conhece. Vale conferir tambĂ©m que o `whatsapp/server.js` foi junto no merge para a `main` — o +> recurso de grupos ainda **nĂŁo estĂĄ lĂĄ**. + +**Correção no cĂłdigo:** o 404 da ponte deixou de aparecer cru. `ClienteWhatsapp#mensagem_de_erro` +agora traduz para *"Ponte do WhatsApp desatualizada (nĂŁo conhece esta rota) — refaça o build do +container `whatsapp`"*. É cosmĂ©tico e **nĂŁo substitui o rebuild**; serve para a prĂłxima vez o erro +dizer o que fazer. + +### 2. O nome "Baileys" saiu da interface + +Nenhum texto que o usuĂĄrio lĂȘ cita mais a biblioteca. A env var mostrada na tela de configuração +virou **`WHATSAPP_URL`**, e `baileys_url_efetiva` lĂȘ nesta ordem: + +``` +banco → ENV['WHATSAPP_URL'] → ENV['BAILEYS_URL'] → 'http://whatsapp:3001' +``` + +O nome antigo continua sendo lido de propĂłsito: **nenhum `.env` jĂĄ em produção quebra**. + +> **Ficou de fora, de propĂłsito:** as colunas `baileys_url` / `baileys_token` e os mĂ©todos +> `baileys?`, `baileys_pronto?`, `BAILEYS_URL_PADRAO` mantĂȘm o nome no banco e no cĂłdigo. +> Renomear exigiria migration + model + controller + service + specs, e **nada disso aparece na +> tela** — os rĂłtulos que o usuĂĄrio lĂȘ jĂĄ eram "Endereço da ponte" e "Token da ponte". O valor +> `'baileys'` do seletor de provedor tambĂ©m continua: Ă© chave interna, o rĂłtulo visĂ­vel Ă© +> "QR code (grĂĄtis, nĂŁo oficial)". + +### 3. Padronização visual das telas de notificação + +As 9 telas do mĂłdulo nasceram com um dialeto prĂłprio e destoavam do resto do admin. O padrĂŁo de +referĂȘncia Ă© **UsuĂĄrios** — a tela mais madura. O que mudou: + +| Antes (telas de notificação) | Agora (padrĂŁo do admin) | +|---|---| +| BotĂŁo `bg-orange-500 text-black`, `py-2.5` | `bg-[#f97316] text-white` com Ă­cone, `min-h-[48px]` | +| `thead` com `bg-white/5`, `py-3`, `font-medium` | `border-b border-white/10`, `py-4`, `font-semibold` | +| AçÔes "Editar"/"Excluir" em texto | Ícones com `opacity-0 group-hover:opacity-100` | +| Vazio: `

` solto fora da tabela | Linha na tabela, Ă­cone `:vazio`, `py-16` | +| Situação em texto colorido | `badge_status_usuario` / `badge_com_icone` | +| Inputs `bg-[#1a1a1a]`, `py-2.5` | `bg-[#0a0a0a]`, `py-3`, `focus:ring` laranja | +| Checkbox de ativo/inativo | Toggle switch, igual ao de UsuĂĄrios | +| FormulĂĄrio solto na pĂĄgina | Card `rounded-2xl border` com seçÔes divididas | + +**CorreçÔes de comportamento que vieram junto:** + +- **`render 'shared/flash'` nas telas que nĂŁo tinham.** Mensagens de sucesso e erro simplesmente + **nĂŁo apareciam** em Contatos, Grupos, Eventos e Envios — a ação dava certo e a tela ficava muda. +- **O aviso do "Buscar grupos" ganhou cor semĂąntica** — verde no sucesso, vermelho no erro. Antes + toda resposta saĂ­a em cinza, entĂŁo "5 grupos encontrados" e "nĂŁo foi possĂ­vel falar com a ponte" + tinham exatamente o mesmo peso visual. +- O rĂłtulo do botĂŁo de busca virou um `` prĂłprio: trocar o `textContent` do botĂŁo inteiro + apagava o Ă­cone junto. + +**Ganhos de leitura:** avatar distingue pessoa de grupo em Contatos; os filtros de Envios ficaram +agrupados por "Situação" e "Canal" com pĂ­lulas; o ponto verde de "Conectado" pulsa; evento +desativado fica esmaecido na lista. + +### 📂 Arquivos +``` +app/models/configuracao_notificacao.rb (WHATSAPP_URL com fallback p/ BAILEYS_URL) +app/services/notificacao/cliente_whatsapp.rb (404 da ponte vira mensagem acionĂĄvel) +.env.example (WHATSAPP_URL; nome antigo documentado) +app/views/admin/configuracao_notificacoes/show.html.erb (nome da lib fora da tela) +app/views/admin/contatos/{index,_form,new,edit}.html.erb +app/views/admin/grupos_contato/{index,_form,new,edit}.html.erb +app/views/admin/eventos_notificacao/{index,_form,new,edit}.html.erb +app/views/admin/notificacao_envios/index.html.erb +app/views/admin/whatsapp_sessoes/show.html.erb +app/views/admin/mensagem_templates/edit.html.erb +``` + +### ⏳ Pendente +```bash +docker compose up -d --build whatsapp # ⚠ o que resolve o "rota desconhecida" +docker compose restart app # views mudaram → cache de views do Puma + +docker compose exec app bundle exec rspec + +# Conferir na tela: NotificaçÔes → Contatos → Novo contato +# → Tipo "Grupo do WhatsApp" → "Buscar grupos" → a lista deve vir com nome, +# nÂș de participantes e ⚠ nos grupos restritos a admin. +``` + +### 🐛 Achado nĂŁo corrigido +`app/views/admin/mensagem_templates/edit.html.erb` carrega o **SortableJS de +`cdn.jsdelivr.net`**. Se a CSP passar de `report_only` para enforcing (pendĂȘncia jĂĄ registrada +neste README), o **arrastar-e-soltar dos blocos morre** — o clique na paleta sobrevive, porque o +cĂłdigo tem `if (window.Sortable)`. O conserto Ă© baixar o arquivo para `public/`. NĂŁo foi feito por +estar fora do escopo pedido. + +

+ +--- + +
+🧭 O que falta: gatilhos pela tela e blocos ricos na mensagem — backlog + +Complementa o backlog do editor jĂĄ registrado acima. **Nada aqui estĂĄ implementado.** + +### A. Criação de gatilhos pela tela + +**Como Ă© hoje:** o ADM cria quantos **eventos** quiser, mas o **gatilho** sai de uma lista fixa de +seis — `manual`, `consolidacao_finalizada`, `pagamento_efetuado`, `valor_alterado`, +`operacao_alterada`, `agendado`. A tela diz isso ao usuĂĄrio: *"o gatilho sai de uma lista fixa, +porque gatilho Ă© cĂłdigo"*. E Ă© verdade — cada gatilho tem uma chamada correspondente no cĂłdigo de +negĂłcio (`Notificacao::Gatilhos`, `NotificacaoService`, controllers). + +**O que "criar gatilho pela tela" realmente significa** — trĂȘs nĂ­veis, do barato ao caro: + +| # | Ideia | Esforço | Observação | +|---|---|---|---| +| A1 | **Gatilho agendado com filtro** — "todo dia 08h **se houver consolidação pendente**". NĂŁo Ă© gatilho novo: Ă© o `agendado` com uma condição escolhida numa lista. | baixo | Cobre boa parte do que se pede como "gatilho novo". | +| A2 | **Gatilho por limiar** — "quando o valor alterado passar de R$ X" ou "quando as notas fora da operação passarem de N". Campos: mĂ©trica (lista), operador, valor. | mĂ©dio | Reusa `Analytics::ResumoOperacao` e `OperacaoSnapshot`, que jĂĄ calculam as mĂ©tricas. | +| A3 | **Gatilhos que faltam no domĂ­nio** — consolidação **arquivada**, contato **sem grupo** hĂĄ X dias, envio **falhado** (avisar o ADM que o WhatsApp caiu). | mĂ©dio | SĂŁo gatilhos de cĂłdigo mesmo: uma constante + a chamada no ponto certo. O caminho jĂĄ estĂĄ pavimentado por `disparar_gatilho`. | +| A4 | **Condição livre por regra** (construtor de expressĂŁo na tela) | **alto** | Vira uma mini-linguagem: precisa de parser, sandbox e validação. **NĂŁo recomendado** — A1+A2 entregam quase tudo por uma fração do custo. | + +> ⚠ **A armadilha aqui:** gatilho cadastrado na tela que **nĂŁo tem chamada no cĂłdigo** fica mudo +> para sempre, sem erro nenhum — exatamente o bug nÂș 4 do deploy de 24/08. Qualquer caminho +> escolhido precisa de uma trava: ou a lista continua vindo do cĂłdigo, ou a tela avisa que o +> gatilho ainda nĂŁo estĂĄ ligado. + +### B. Mais opçÔes na criação da mensagem + +**Como Ă© hoje:** 7 blocos (`cabecalho`, `texto`, `tabela`, `aviso`, `botao`, `divisor`, `rodape`), +todos **sĂł texto**, com cores fixas no renderizador. + +| # | Ideia | Esforço | Observação | +|---|---|---|---| +| B1 | **Seletor de emoji** nos campos de texto — hoje sĂł colando do sistema. Emoji Ă© o que dĂĄ cara de WhatsApp Ă  mensagem. | baixo | Um picker pequeno, sem dependĂȘncia externa (a CSP Ă© restritiva). Inserir no cursor: a mecĂąnica jĂĄ existe, Ă© a mesma dos chips de variĂĄvel. | +| B2 | **Bloco de imagem no e-mail** — `` com URL ou upload. | mĂ©dio | Cliente de e-mail bloqueia imagem remota por padrĂŁo → precisa de URL absoluta pĂșblica ou base64. | +| B3 | **Bloco de imagem no WhatsApp** | **alto** | A ponte hoje sĂł faz `sendMessage` de **texto**. Exige `sendMessage(jid, { image })` no `server.js`, endpoint novo, envio multipart no `ClienteWhatsapp`, storage do arquivo e uma coluna no log de envios. **É o item mais caro da lista** — vale sĂł se a demanda for real. | +| B4 | **Formatação do WhatsApp** (`*negrito*`, `_itĂĄlico_`, `~riscado~`, ``` `mono` ```) com botĂ”es — hoje o ADM precisa saber a sintaxe de cor. | baixo | No e-mail cada marca vira a tag equivalente. | +| B5 | **Bloco de lista com bullets** — hoje sĂł existe tabela rĂłtulo/valor. | baixo | JĂĄ estava listado como B7 no backlog anterior. | +| B6 | **Ocultar bloco com variĂĄvel vazia** — resolve o `"⚠ AlteraçÔes: "` sem nada depois. | baixo | JĂĄ listado como B1 antes; **continua sendo o melhor valor Ă· esforço do mĂłdulo inteiro**. | +| B7 | **Cor de destaque configurĂĄvel** — o laranja estĂĄ fixo no `Renderizador`. | baixo | | +| B8 | **Contador de caracteres no WhatsApp** — mensagem longa vira "ler mais", e o começo Ă© o que decide se abrem. | baixo | | +| B9 | **Bloco de menção** (`@membro`) em grupo do WhatsApp | mĂ©dio | Baileys exige passar os JIDs em `mentions` alĂ©m do texto — nĂŁo basta escrever `@`. | + +### Se fosse escolher trĂȘs +**B6** (mensagem sem buraco quando a variĂĄvel vem vazia), **B1** (emoji) e **B4** (formatação do +WhatsApp por botĂŁo). Os trĂȘs sĂŁo de esforço baixo e atacam o que mais se sente escrevendo mensagem +no dia a dia. **B3 (imagem no WhatsApp) fica por Ășltimo** — Ă© o Ășnico que mexe na ponte, no cliente +Ruby e no schema ao mesmo tempo. + +
-- 2.49.1 From 8d24a575794c8193026ad829b971fc27d1a604522f47201103c44a66f44db35b Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 10:56:07 -0300 Subject: [PATCH 11/40] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20do=20Para=20corre?= =?UTF-8?q?=C3=A7=C3=A3o=20do=20valor=20do=20dash=20da=20opera=C3=A7=C3=A3?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../operacoes_dashboard_controller.rb | 34 +++++++++---------- app/services/analytics/operacao_metricas.rb | 16 +++++++++ app/views/operacoes_dashboard/index.html.erb | 20 +++++++++-- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index ca48bd0..31c53a4 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -35,13 +35,19 @@ class OperacoesDashboardController < ApplicationController @metricas_a = montar([@op_a]) if @op_a @metricas_b = montar([@op_b]) if @op_b else - # Operação Ășnica. Sem datas na URL o recorte Ă© a operação inteira (o - # perĂ­odo vem dos prĂłprios dados) — Ă© o que mantĂ©m utilizĂĄvel abrir uma - # operação de meses atrĂĄs. Quando o operador ESCOLHE um perĂ­odo, ele passa - # a valer aqui tambĂ©m: sem isso, comparar esta tela com o dashboard - # financeiro "no mesmo perĂ­odo" era impossĂ­vel, porque a data era ignorada. + # Operação Ășnica: o recorte Ă© SEMPRE a operação inteira, mesmo com + # inicio/fim na URL. É o que confere com os documentos fĂ­sicos e com a + # planilha entregue ao cliente (Analytics::PlanilhaEntregas tambĂ©m nĂŁo + # filtra data). Recortar pelo perĂ­odo derrubava as entregas que a operação + # executou fora do mĂȘs do nome dela — EMAD AGO 2026 rodou de 30/07 a + # 12/08, e "01/08 → hoje" mostrava 1146 das 2021 NFs. + # + # O perĂ­odo escolhido continua tendo função aqui: alimenta as "Notas fora + # da operação" e aparece no cabeçalho como REFERÊNCIA (quantas NFs da + # operação caĂ­ram na faixa), para comparar com o dashboard financeiro sem + # mexer no nĂșmero que o cliente confere. @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first - @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro, **faixa_ativa) if @operacao + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao @fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) if @periodo_explicito end end @@ -63,8 +69,10 @@ class OperacoesDashboardController < ApplicationController if @modo == 'global' @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) else + # Mesma regra do #index: operação = operação inteira (a planilha espelho + # tem que bater com a que Ă© entregue ao cliente). @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first - @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro, **faixa_ativa) if @operacao + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao end montar_tabela_espelho if @metricas @@ -108,20 +116,12 @@ class OperacoesDashboardController < ApplicationController @espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || [] end - # PerĂ­odo escolhido pelo operador (veio na URL) x default da tela. SĂł o - # explĂ­cito Ă© aplicado no modo Operação — ver o comentĂĄrio no #index. + # PerĂ­odo escolhido pelo operador (veio na URL) x default da tela. No modo + # Operação ele nĂŁo recorta os KPIs — ver o comentĂĄrio no #index. def periodo_explicito? params[:inicio].present? || params[:fim].present? end - # Faixa a repassar para as mĂ©tricas no modo Operação: vazia quando o operador - # nĂŁo escolheu perĂ­odo (aĂ­ o recorte Ă© a operação inteira). - def faixa_ativa - return {} unless @periodo_explicito - - { inicio: @periodo_inicio, fim: @periodo_fim } - end - def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data) end diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb index a2304b0..bef84b4 100644 --- a/app/services/analytics/operacao_metricas.rb +++ b/app/services/analytics/operacao_metricas.rb @@ -126,6 +126,22 @@ module Analytics total - sucesso - recusas end + # NFs da operação cujo resultado caiu dentro de uma faixa de datas — + # REFERÊNCIA para o cabeçalho da tela de operação Ășnica, onde o total + # continua sendo a operação inteira (Ă© o que confere com a planilha do + # cliente). Mesma regra do filtro SQL: concluĂ­das/falhas pela data real + # (checkout), pendentes pela data planejada. + def total_no_periodo(inicio, fim) + return total unless inicio && fim + + ini = inicio.to_date + fim = fim.to_date + linhas.count do |r| + d = data_de(r['checkout']) || data_de(r['planned_date']) + d && d >= ini && d <= fim + end + end + # ── Camada operacional (por VISITA, nĂŁo por NF) ─────────────── # Os KPIs acima contam NOTAS porque Ă© o que o cliente paga e confere. Estes # contam IDAS AO LOCAL — Ă© o que o motorista recebe e o que o dashboard diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index d3e8abc..98d3638 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -57,7 +57,15 @@ <%= icone :comparar %> Comparação entre operaçÔes (cada uma no seu prĂłprio perĂ­odo) <% elsif @metricas&.data_inicio %> <%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> – <%= @metricas.data_fim.strftime('%d/%m/%Y') %> - <%= @periodo_explicito ? '(perĂ­odo filtrado)' : '(operação inteira)' %> + (operação inteira) + <%# O perĂ­odo escolhido NÃO recorta os KPIs desta tela (eles tĂȘm que + bater com a planilha entregue ao cliente); ele aparece aqui como + referĂȘncia para comparar com o dashboard financeiro. %> + <% if @periodo_explicito %> + · + no perĂ­odo <%= @periodo_inicio.strftime('%d/%m') %>–<%= @periodo_fim.strftime('%d/%m/%Y') %>: + <%= @metricas.total_no_periodo(@periodo_inicio, @periodo_fim) %> NFs + <% end %> <% elsif @operacao %> <%= Operacao.label(@operacao) %> <% end %> @@ -75,6 +83,7 @@ <%= icone :calendario, espaco: false %>
@@ -89,9 +98,14 @@ data: { turbo: false }, class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> <% end %> - <%# Volta ao recorte natural da operação (sem faixa de datas na URL). %> + <%# Limpa a faixa de datas da URL (os KPIs jĂĄ sĂŁo sempre da operação + inteira; sem perĂ­odo a tela tambĂ©m some com o painel de notas + fora da operação). %> <% if @modo == 'operacao' %> - <%= link_to 'Operação inteira', + <%# RĂłtulo Ă© "Limpar perĂ­odo" e nĂŁo "Operação inteira": o total jĂĄ Ă© + sempre o da operação inteira, o que este botĂŁo faz Ă© tirar a + faixa de datas da URL. %> + <%= link_to 'Limpar perĂ­odo', operacoes_dashboard_path(ctx_ops.merge(modo: 'operacao')), data: { turbo: false }, class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{@periodo_explicito ? 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500' : 'bg-orange-500 text-black border-orange-500 font-bold'}" %> -- 2.49.1 From cd99a225538fa93e5bf562c4d31f31b916e17f0618033a4d241618a44b543881 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 11:15:52 -0300 Subject: [PATCH 12/40] =?UTF-8?q?Cria=C3=A7=C3=A3o=20da=20vis=C3=A3o=20ger?= =?UTF-8?q?al=20da=20consolida=C3=A7=C3=A3o=20por=20motorista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/consolidacoes_controller.rb | 59 ++++++ .../motorista/dashboard_controller.rb | 52 +++++ .../analytics/totais_por_motorista.rb | 111 ++++++++++ app/services/pdf/totais_motorista_pdf.rb | 130 ++++++++++++ app/views/consolidacoes/index.html.erb | 2 + app/views/consolidacoes/totais.html.erb | 191 ++++++++++++++++++ app/views/layouts/_navbar.html.erb | 2 + app/views/motorista/dashboard/index.html.erb | 10 +- app/views/motorista/dashboard/totais.html.erb | 95 +++++++++ config/routes.rb | 10 + spec/requests/totais_por_motorista_spec.rb | 90 +++++++++ .../analytics/totais_por_motorista_spec.rb | 92 +++++++++ 12 files changed, 841 insertions(+), 3 deletions(-) create mode 100644 app/services/analytics/totais_por_motorista.rb create mode 100644 app/services/pdf/totais_motorista_pdf.rb create mode 100644 app/views/consolidacoes/totais.html.erb create mode 100644 app/views/motorista/dashboard/totais.html.erb create mode 100644 spec/requests/totais_por_motorista_spec.rb create mode 100644 spec/services/analytics/totais_por_motorista_spec.rb diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index b8fe1cd..30a09fa 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -40,6 +40,47 @@ class ConsolidacoesController < ApplicationController end end + # GET /consolidacoes/totais?inicio=&fim=&motorista=&rascunhos=1 + # + # "Quanto cada motorista somou no perĂ­odo". A lista de consolidaçÔes mostra um + # card por CONSOLIDAÇÃO, mas o pagamento Ă© por MOTORISTA: para saber quanto um + # motorista fechou no mĂȘs era preciso abrir consolidação por consolidação e + # somar Ă  mĂŁo. Aqui o total vem pronto e, clicando no motorista (?motorista=), + # a tela abre as consolidaçÔes que compĂ”em aquele valor. + def totais + authorize Consolidacao, :index? + + @inicio, @fim = periodo_totais + @incluir_rascunhos = params[:rascunhos] == '1' + @motorista = params[:motorista].presence + @totais = Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, incluir_rascunhos: @incluir_rascunhos + ) + # Drill-down: as consolidaçÔes do motorista clicado (a tabela geral continua + # na tela, entĂŁo o service Ă© montado sem recorte de motorista). + @detalhe = @motorista ? @totais.consolidacoes_de(@motorista) : nil + end + + # GET /consolidacoes/totais_pdf — o mesmo relatĂłrio em PDF. Com ?motorista= + # sai sĂł o extrato daquele motorista (com espaço de assinatura). + def totais_pdf + authorize Consolidacao, :index? + + inicio, fim = periodo_totais + totais = Analytics::TotaisPorMotorista.new( + inicio: inicio, fim: fim, + motorista: params[:motorista].presence, + incluir_rascunhos: params[:rascunhos] == '1' + ) + + nome = params[:motorista].presence&.parameterize(separator: '_') + arquivo = ['totais', nome, inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].compact.join('_') + send_data Pdf::TotaisMotoristaPdf.new(totais: totais, detalhar: params[:detalhar] != '0').render, + filename: "#{arquivo}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + # GET /consolidacoes/new def new authorize Consolidacao @@ -449,6 +490,24 @@ class ConsolidacoesController < ApplicationController private + # PerĂ­odo do relatĂłrio de totais. Default: o MÊS CORRENTE INTEIRO — o fim Ă© o + # Ășltimo dia do mĂȘs, nĂŁo "hoje", porque o recorte exige a consolidação inteira + # dentro da faixa (Consolidacao.no_periodo) e um fechamento que vai atĂ© 31/08 + # sumiria da tela atĂ© o dia 31 chegar. + def periodo_totais + inicio = parse_data_totais(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data_totais(params[:fim]) || Date.current.end_of_month + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data_totais(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end + # Acha o User motorista pelo nome para gerar o QR Code de acesso. O # motorista_nome vem dos dados externos de entrega e o users.nome Ă© digitado # no admin — entĂŁo toleramos diferenças de caixa e de espaços (extras/duplos). diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 6a1e903..86bb26c 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -54,8 +54,60 @@ module Motorista .includes(:consolidacao) end + # GET /motorista/totais?inicio=&fim= + # + # "Quanto eu fechei neste perĂ­odo e de onde vem esse valor". O card do painel + # mostra sĂł o total acumulado; aqui o motorista escolhe o perĂ­odo, vĂȘ o total + # FECHADO (consolidaçÔes finalizadas) e a lista de consolidaçÔes que compĂ”em + # o valor — a mesma conta que o admin vĂȘ em /consolidacoes/totais, montada + # pelo mesmo service para os dois nĂșmeros nĂŁo poderem divergir. + def totais + @inicio, @fim = periodo_totais + @totais = totais_do_periodo + @consolidacoes = @totais.consolidacoes_de(current_user.nome) + end + + # GET /motorista/totais/pdf — o extrato do perĂ­odo em PDF (com assinatura), + # para o motorista guardar ou mandar por WhatsApp. + def totais_pdf + inicio, fim = periodo_totais + totais = Analytics::TotaisPorMotorista.new( + inicio: inicio, fim: fim, motorista: current_user.nome + ) + + arquivo = ['meu_total', current_user.nome.parameterize(separator: '_'), + inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].join('_') + send_data Pdf::TotaisMotoristaPdf.new(totais: totais).render, + filename: "#{arquivo}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + private + def totais_do_periodo + Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, motorista: current_user.nome + ) + end + + # Default: mĂȘs corrente INTEIRO (fim = Ășltimo dia do mĂȘs). O recorte pede a + # consolidação inteira dentro da faixa, entĂŁo terminar em "hoje" esconderia + # um fechamento que vai atĂ© o fim do mĂȘs. Mesma regra do relatĂłrio do admin. + def periodo_totais + inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data(params[:fim]) || Date.current.end_of_month + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end + def garantir_motorista return if current_user.motorista? redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.' diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb new file mode 100644 index 0000000..3112401 --- /dev/null +++ b/app/services/analytics/totais_por_motorista.rb @@ -0,0 +1,111 @@ +# app/services/analytics/totais_por_motorista.rb +# +# Quanto cada motorista somou no perĂ­odo — e QUAIS consolidaçÔes formam esse +# total. É a fonte Ășnica das duas telas de "total por motorista": +# +# ‱ admin → /consolidacoes/totais (todos os motoristas + drill-down) +# ‱ motorista→ /motorista/totais (sĂł ele, mesma conta, mesmo PDF) +# +# RECORTE (igual ao filtro da lista de consolidaçÔes): entram as consolidaçÔes +# ATIVAS cujo perĂ­odo estĂĄ DENTRO da faixa escolhida (data_inicio >= inicio e +# data_fim <= fim). NĂŁo Ă© sobreposição de propĂłsito — uma consolidação que +# atravessa a borda do filtro entraria inteira e o "total do perĂ­odo" ficaria +# maior que o perĂ­odo. +# +# SĂł motoristas ATIVOS (nĂŁo arquivados) contam: Ă© o mesmo conjunto que a +# consolidação paga (ConsolidacaoMotorista.ativos), entĂŁo o total desta tela +# bate com a soma dos fechamentos. +module Analytics + class TotaisPorMotorista + def initialize(inicio:, fim:, motorista: nil, incluir_rascunhos: false) + @inicio = inicio.to_date + @fim = fim.to_date + @motorista = motorista.presence + @incluir_rascunhos = incluir_rascunhos + end + + attr_reader :inicio, :fim, :motorista + + # ConsolidacaoMotorista do recorte, com a consolidação jĂĄ carregada (as duas + # telas mostram nome/perĂ­odo/status dela em seguida). + def registros + @registros ||= begin + base = Consolidacao.ativas.no_periodo(@inicio, @fim) + # SĂł o que jĂĄ foi FECHADO conta como total do perĂ­odo; rascunho ainda + # muda de valor. `merge` (e nĂŁo where(consolidacoes: {status: ...})) + # porque status Ă© enum: quem sabe traduzir :finalizada Ă© o prĂłprio model. + base = base.finalizada unless @incluir_rascunhos + + escopo = ConsolidacaoMotorista.ativos + .joins(:consolidacao) + .merge(base) + .includes(:consolidacao) + escopo = escopo.where(motorista_nome: @motorista) if @motorista + escopo.to_a + end + end + + # Uma linha por motorista, do maior total para o menor. + def linhas + @linhas ||= registros.group_by(&:motorista_nome).map do |nome, cms| + pagos = cms.select(&:pago?) + pendentes = cms.reject(&:pago?) + { + motorista: nome, + consolidacoes: cms.size, + lancamentos: lancamentos_por_motorista[nome].to_i, + valor_total: soma(cms), + valor_pago: soma(pagos), + valor_pendente: soma(pendentes), + pagas: pagos.size, + pendentes: pendentes.size + } + end.sort_by { |l| -l[:valor_total] } + end + + # ConsolidaçÔes que compĂ”em o total de UM motorista (o drill-down), da mais + # recente para a mais antiga. + def consolidacoes_de(nome) + registros.select { |cm| cm.motorista_nome == nome } + .sort_by { |cm| [cm.consolidacao.data_fim, cm.consolidacao.data_inicio] } + .reverse + end + + def motoristas + linhas.map { |l| l[:motorista] } + end + + def total_geral = soma(registros) + def total_pago = soma(registros.select(&:pago?)) + def total_pendente = soma(registros.reject(&:pago?)) + def total_consolidacoes = registros.map(&:consolidacao_id).uniq.size + def vazio? = registros.empty? + + def periodo_label + "#{@inicio.strftime('%d/%m/%Y')} a #{@fim.strftime('%d/%m/%Y')}" + end + + private + + def soma(cms) + cms.sum { |cm| cm.valor_total || 0 } + end + + # Lançamentos classificados por motorista (soma da quantidade — um termo com + # quantidade 5 vale 5). Uma query sĂł para todas as consolidaçÔes do recorte, + # em vez de uma por linha da tabela. + def lancamentos_por_motorista + @lancamentos_por_motorista ||= begin + ids = registros.map(&:consolidacao_id).uniq + if ids.empty? + {} + else + ConsolidacaoEntrega.where(consolidacao_id: ids, + motorista_nome: registros.map(&:motorista_nome).uniq) + .group(:motorista_nome) + .sum(:quantidade) + end + end + end + end +end diff --git a/app/services/pdf/totais_motorista_pdf.rb b/app/services/pdf/totais_motorista_pdf.rb new file mode 100644 index 0000000..c9e44b8 --- /dev/null +++ b/app/services/pdf/totais_motorista_pdf.rb @@ -0,0 +1,130 @@ +# app/services/pdf/totais_motorista_pdf.rb +# +# RelatĂłrio "total por motorista no perĂ­odo" — o mesmo documento serve Ă s duas +# telas, porque os nĂșmeros vĂȘm do mesmo Analytics::TotaisPorMotorista: +# +# ‱ admin (todos os motoristas): resumo por motorista + as consolidaçÔes que +# compĂ”em o total de cada um; +# ‱ motorista (um sĂł): o total fechado dele no perĂ­odo + as consolidaçÔes que +# formam esse valor, com espaço de assinatura para servir de comprovante. +# +module Pdf + class TotaisMotoristaPdf < BasePdf + VERDE = '22C55E' + AMBAR = 'D97706' + + # totais: Analytics::TotaisPorMotorista jĂĄ montado (ele conhece o recorte). + # detalhar: inclui, para cada motorista, a lista de consolidaçÔes. Sempre + # ligado quando o relatĂłrio Ă© de um motorista sĂł. + def initialize(totais:, detalhar: true, titulo: nil) + super() + @t = totais + @detalhar = detalhar || @t.motorista.present? + @titulo = titulo + end + + private + + def corpo + secao(@titulo || (@t.motorista ? 'MEU TOTAL NO PERÍODO' : 'TOTAL POR MOTORISTA')) + + @pdf.text "PerĂ­odo: #{@t.periodo_label}", size: 11, style: :bold + @pdf.fill_color CINZA + @pdf.text(@t.motorista ? "Motorista: #{@t.motorista}" : "Motoristas: #{@t.linhas.size}", size: 10) + @pdf.text "ConsolidaçÔes no perĂ­odo: #{@t.total_consolidacoes}", size: 10 + @pdf.fill_color '000000' + + secao 'TOTAIS' + kpis + + if @t.vazio? + @pdf.fill_color CINZA + @pdf.text 'Nenhuma consolidação fechada neste perĂ­odo.', size: 10 + @pdf.fill_color '000000' + return + end + + unless @t.motorista + secao 'RESUMO POR MOTORISTA' + tabela_resumo + end + + return unless @detalhar + + @t.linhas.each do |linha| + @pdf.start_new_page if @pdf.cursor < 160 + secao "COMPOSIÇÃO — #{linha[:motorista].upcase}" + tabela_composicao(@t.consolidacoes_de(linha[:motorista])) + end + + assinaturas(motorista: @t.motorista) if @t.motorista + end + + def kpis + linhas = [ + ['Total do perĂ­odo', moeda(@t.total_geral)], + ['Pago', moeda(@t.total_pago)], + ['A receber', moeda(@t.total_pendente)] + ] + @pdf.table(linhas, width: 300, cell_style: { size: 11, padding: [6, 10] }) do |t| + t.columns(0).font_style = :bold + t.columns(1).align = :right + t.row(1).text_color = VERDE + t.row(2).text_color = AMBAR + t.cells.borders = [:bottom] + t.cells.border_color = 'DDDDDD' + end + end + + def tabela_resumo + linhas = [['Motorista', 'Consol.', 'Lanç.', 'Pago', 'A receber', 'Total']] + @t.linhas.each do |l| + linhas << [l[:motorista], l[:consolidacoes].to_s, l[:lancamentos].to_s, + moeda(l[:valor_pago]), moeda(l[:valor_pendente]), moeda(l[:valor_total])] + end + linhas << ['TOTAL', @t.total_consolidacoes.to_s, '', + moeda(@t.total_pago), moeda(@t.total_pendente), moeda(@t.total_geral)] + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + column_widths: { 1 => 50, 2 => 50, 3 => 80, 4 => 80, 5 => 85 }, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(1..5).align = :right + t.row(-1).font_style = :bold + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + end + + # As consolidaçÔes que compĂ”em o valor — Ă© o "clicar no motorista" da tela, + # em papel. + def tabela_composicao(cms) + linhas = [['Consolidação', 'PerĂ­odo', 'Situação', 'Pago em', 'Valor']] + cms.each do |cm| + c = cm.consolidacao + linhas << [ + c.nome, + "#{c.data_inicio.strftime('%d/%m/%y')}–#{c.data_fim.strftime('%d/%m/%y')}", + cm.pago? ? 'Pago' : 'A receber', + cm.pago? ? cm.pago_em.strftime('%d/%m/%Y') : '—', + moeda(cm.valor_total) + ] + end + linhas << ['TOTAL', '', '', '', moeda(cms.sum { |cm| cm.valor_total || 0 })] + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + column_widths: { 1 => 90, 2 => 70, 3 => 70, 4 => 80 }, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(4).align = :right + t.row(-1).font_style = :bold + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + end + end +end diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 47b5202..e8eaf82 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -6,6 +6,8 @@

<%= icone :consolidacoes, espaco: false %> ConsolidaçÔes

+ <%= link_to rotulo(:motorista, 'Totais por motorista', cor: nil), totais_consolidacoes_path, + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> <%= link_to rotulo(:arquivadas, 'Arquivadas', cor: nil), arquivadas_consolidacoes_path, class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> <% if policy(Consolidacao).create? %> diff --git a/app/views/consolidacoes/totais.html.erb b/app/views/consolidacoes/totais.html.erb new file mode 100644 index 0000000..2e5ad16 --- /dev/null +++ b/app/views/consolidacoes/totais.html.erb @@ -0,0 +1,191 @@ +<%# app/views/consolidacoes/totais.html.erb — Total por motorista no período %> +<% content_for :title, 'Totais por motorista' %> + +
+
+

<%= icone :motorista, espaco: false %> Totais por motorista

+

+ ConsolidaçÔes <%= @incluir_rascunhos ? 'de qualquer status' : 'finalizadas' %> + com perĂ­odo dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %> +

+
+
+ <%= link_to rotulo(:consolidacoes, 'ConsolidaçÔes', cor: nil), consolidacoes_path, + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> + <%= link_to rotulo(:baixar, 'Baixar relatório (PDF)', cor: nil), + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), motorista: @motorista), + data: { turbo: false }, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> +
+
+ +<%# ── Filtros ──────────────────────────────────────────── %> +<%= form_with url: totais_consolidacoes_path, method: :get, + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 flex flex-wrap items-center gap-3' do |f| %> + <%# O motorista clicado viaja junto para o filtro não fechar o drill-down. %> + <%= hidden_field_tag :motorista, @motorista if @motorista %> + + <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), + 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: @fim.strftime('%Y-%m-%d'), + 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.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', totais_consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %> +
+<% end %> + +<%# ── KPIs do período ──────────────────────────────────── %> +
+
+

<%= icone :dinheiro %> Total do perĂ­odo

+

<%= moeda(@totais.total_geral) %>

+

<%= @totais.total_consolidacoes %> consolidação(Ôes) · <%= @totais.linhas.size %> motorista(s)

+
+
+

<%= icone :sucesso, cor: 'text-green-400' %> Pago

+

<%= moeda(@totais.total_pago) %>

+
+
+

<%= icone :parcial, cor: 'text-amber-400' %> A pagar

+

<%= moeda(@totais.total_pendente) %>

+
+
+

<%= icone :ticket %> Média por motorista

+

+ <%= moeda(@totais.linhas.any? ? @totais.total_geral / @totais.linhas.size : 0) %> +

+
+
+ +<% if @totais.vazio? %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhuma consolidação <%= @incluir_rascunhos ? '' : 'finalizada ' %>com período dentro dessa faixa.

+

Amplie o perĂ­odo ou marque "Incluir rascunhos".

+
+<% else %> + <%# ── Tabela: total por motorista (clique abre a composição) ── %> +
+
+ + + + + + + + + + + + + <% @totais.linhas.each do |l| %> + <% aberto = @motorista == l[:motorista] %> + + <%# O nome é um LINK de verdade (e não um ): abre/fecha + a composição daquele motorista e continua acessível pelo teclado. %> + + + + + + + + <% end %> + + + + + + + + + + + +
MotoristaConsolidaçÔesLançamentosPagoA pagarTotal
+ <%= link_to totais_consolidacoes_path(inicio: @inicio, fim: @fim, + rascunhos: (@incluir_rascunhos ? '1' : nil), + motorista: (aberto ? nil : l[:motorista])), + class: 'text-white font-semibold hover:text-orange-500 flex items-center gap-1' do %> + <%= icone(aberto ? :expandir : :avancar, cor: 'text-gray-500', espaco: false) %><%= l[:motorista] %> + <% end %> + <%= l[:consolidacoes] %><%= l[:lancamentos] %><%= moeda(l[:valor_pago]) %><%= moeda(l[:valor_pendente]) %><%= moeda(l[:valor_total]) %>
TOTAL<%= @totais.total_consolidacoes %><%= moeda(@totais.total_pago) %><%= moeda(@totais.total_pendente) %><%= moeda(@totais.total_geral) %>
+
+
+

+ <%= icone :dica %> Clique num motorista para ver as consolidaçÔes que compÔem o total dele. +

+ <%# PDF sem a composição de cada motorista — para períodos com muita gente, + quando só o resumo interessa. %> + <%= link_to 'Baixar só o resumo (PDF)', + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), detalhar: '0'), + data: { turbo: false }, + class: 'text-gray-400 hover:text-orange-500 text-xs underline whitespace-nowrap' %> +
+
+ + <%# ── Drill-down: as consolidaçÔes que compĂ”em o total ── %> + <% if @detalhe %> +
+
+
+

+ <%= icone :composicao, espaco: false %> Composição de <%= @motorista %> +

+

+ <%= @detalhe.size %> consolidação(Ôes) no período · + <%= moeda(@detalhe.sum { |cm| cm.valor_total || 0 }) %> +

+
+
+ <%= link_to rotulo(:baixar, 'PDF deste motorista', cor: nil), + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), motorista: @motorista), + data: { turbo: false }, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-2.5 rounded-lg flex items-center' %> + <%= link_to rotulo(:fechar, 'Fechar', cor: nil), + totais_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil)), + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-2.5 rounded-lg flex items-center' %> +
+
+ + <% if @detalhe.empty? %> +

Nenhuma consolidação desse motorista neste período.

+ <% end %> + +
+ <% @detalhe.each do |cm| %> + <% c = cm.consolidacao %> + <%= link_to consolidacao_path(c), + class: 'block bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-4 transition-colors' do %> +
+
+
+ <%= c.nome %> + <%= badge_status(c.status) %> + <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> +
+

+ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> + <% if cm.pago? %> + · <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> + <% if cm.nota_fiscal.present? %>· NF <%= cm.nota_fiscal %><% end %> + <% end %> +

+
+

<%= moeda(cm.valor_total) %>

+
+ <% end %> + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 5e6e8f7..3a2e6f3 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -43,12 +43,14 @@ <%# ConsolidaçÔes — sĂł quem pode consolidar (exclui o externo) %> <% if current_user.pode_consolidar? %> <%= nav_link_to 'ConsolidaçÔes', consolidacoes_path, icon: :consolidacoes %> + <%= nav_link_to 'Totais por motorista', totais_consolidacoes_path, icon: :motorista %> <%= nav_link_to 'Arquivadas', arquivadas_consolidacoes_path, icon: :arquivadas %> <% end %> <%# Motorista %> <% if current_user.motorista? %> <%= nav_link_to 'Meu Painel', motorista_dashboard_path, icon: :painel %> + <%= nav_link_to 'Meu total do perĂ­odo', motorista_totais_path, icon: :dinheiro %> <% end %> <%# Admin/Gerente — ConfiguraçÔes %> diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index af8d8d3..bdde49c 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -51,14 +51,18 @@

- <%# Card 2 — Valor Consolidado (BRANCO) %> -
+ <%# Card 2 — Valor Consolidado (BRANCO). ClicĂĄvel: leva ao total POR PERÍODO, + com as consolidaçÔes que compĂ”em o valor e o PDF do perĂ­odo. %> + <%= link_to motorista_totais_path, class: 'block bg-white rounded-2xl p-6 hover:ring-2 hover:ring-orange-500 transition' do %>

<%= icone :sucesso, cor: 'text-green-600', espaco: false %> Valor fechado para pagamento

<%= moeda(@valor_consolidado) %>

Soma dos pagamentos jĂĄ confirmados pela empresa

-
+

+ <%= icone :avancar, cor: 'text-orange-600' %> Ver por perĂ­odo e baixar o PDF +

+ <% end %>
<%# ── Lista de consolidaçÔes ──────────────────────────── %> diff --git a/app/views/motorista/dashboard/totais.html.erb b/app/views/motorista/dashboard/totais.html.erb new file mode 100644 index 0000000..c161ca1 --- /dev/null +++ b/app/views/motorista/dashboard/totais.html.erb @@ -0,0 +1,95 @@ +<%# app/views/motorista/dashboard/totais.html.erb — Meu total no perĂ­odo %> +<% content_for :title, 'Meu total no perĂ­odo' %> + +
+ +
+

Meu total no perĂ­odo

+ <%= link_to rotulo(:voltar, 'Voltar', cor: nil), motorista_dashboard_path, + class: 'text-gray-300 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-xl min-h-[48px] flex items-center' %> +
+ + <%# ── Período ──────────────────────────────────────────── %> + <%= form_with url: motorista_totais_path, method: :get, + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-4 mb-5 flex flex-wrap items-end gap-3' do |f| %> +
+ + <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> +
+
+ + <%= f.date_field :fim, value: @fim.strftime('%Y-%m-%d'), + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> +
+ <%= f.submit 'Ver', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl cursor-pointer px-6 py-3 min-h-[48px]' %> + <% end %> + + <%# ── Total fechado ────────────────────────────────────── %> +
+

+ <%= icone :sucesso, cor: 'text-green-600', espaco: false %> Total fechado no perĂ­odo +

+

<%= icone :calendario, cor: 'text-gray-500' %> <%= l @inicio, format: :short %> a <%= l @fim, format: :short %>

+

<%= moeda(@totais.total_geral) %>

+
+ <%= icone :dinheiro, cor: 'text-green-700' %> JĂĄ pago: <%= moeda(@totais.total_pago) %> + <%= icone :parcial, cor: 'text-amber-700' %> A receber: <%= moeda(@totais.total_pendente) %> +
+

+ Soma de <%= @consolidacoes.size %> fechamento(s) da empresa neste perĂ­odo. +

+
+ + <%= link_to rotulo(:baixar, 'Baixar relatĂłrio em PDF', cor: nil), + motorista_totais_pdf_path(inicio: @inicio, fim: @fim), + data: { turbo: false }, + class: 'w-full bg-orange-500 hover:bg-orange-600 text-black font-bold text-base px-6 py-4 rounded-xl min-h-[52px] flex items-center justify-center mb-6' %> + + <%# ── O que compĂ”e o valor ─────────────────────────────── %> +

<%= icone :composicao, espaco: false %> O que forma esse valor

+

Cada fechamento abaixo entra na soma acima.

+ + <% if @consolidacoes.any? %> +
+ <% @consolidacoes.each do |cm| %> + <% c = cm.consolidacao %> +
+
+
+
+

<%= c.nome %>

+ <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> +
+

+ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %> +

+

<%= moeda(cm.valor_total) %>

+ <% if cm.pago? %> +

+ <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> +

+ <% if cm.nota_fiscal.present? %> +

<%= icone :nota_fiscal %> Nota Fiscal: <%= cm.nota_fiscal %>

+ <% end %> + <% else %> +

<%= icone :parcial, cor: 'text-amber-400' %> Ainda nĂŁo pago

+ <% end %> +
+ <%= link_to rotulo(:baixar, 'Extrato deste fechamento', cor: nil), + gerar_pdf_extrato_consolidacao_path(c, motorista: current_user.nome), + data: { turbo: false }, + class: 'w-full md:w-auto bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 text-white font-bold text-base px-5 py-4 rounded-xl min-h-[52px] flex items-center justify-center text-center' %> +
+
+ <% end %> +
+ <% else %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhum fechamento neste perĂ­odo.

+

Escolha outro período acima — o fechamento aparece aqui quando a empresa finaliza a consolidação.

+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index ec50104..4c90e09 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,10 @@ Rails.application.routes.draw do # Painel motorista get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard + # Total fechado do motorista num perĂ­odo + as consolidaçÔes que compĂ”em o + # valor (mesma conta do relatĂłrio do admin) e o PDF do extrato do perĂ­odo. + get '/motorista/totais', to: 'motorista/dashboard#totais', as: :motorista_totais + get '/motorista/totais/pdf', to: 'motorista/dashboard#totais_pdf', as: :motorista_totais_pdf # Login motorista — PIN de 4 dĂ­gitos + acesso via QR Code do extrato get '/motorista/login', to: 'motorista/sessoes#new', as: :motorista_login @@ -42,6 +46,12 @@ Rails.application.routes.draw do get :buscar_nf # JSON — busca a NF na rastreio (nota avulsa) post :avulsa # cria consolidação avulsa a partir de uma NF get :arquivadas # HistĂłrico — consolidaçÔes arquivadas + + # RelatĂłrio "total por motorista no perĂ­odo": soma o que cada motorista + # fechou na faixa e, ao clicar num deles (?motorista=), mostra as + # consolidaçÔes que compĂ”em o valor. `totais_pdf` Ă© o mesmo relatĂłrio em PDF. + get :totais + get :totais_pdf end member do diff --git a/spec/requests/totais_por_motorista_spec.rb b/spec/requests/totais_por_motorista_spec.rb new file mode 100644 index 0000000..0f9b151 --- /dev/null +++ b/spec/requests/totais_por_motorista_spec.rb @@ -0,0 +1,90 @@ +require 'rails_helper' + +# Rotas do relatĂłrio "total por motorista": quem entra, o que a tela mostra e o +# PDF. O drill-down (?motorista=) Ă© o que responde "de onde vem esse valor". +RSpec.describe 'Totais por motorista', type: :request do + def consolidacao_finalizada(motorista:, valor:, nome: 'Fechamento Junho') + c = create(:consolidacao, nome: nome, status: :finalizada, + data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30)) + create(:consolidacao_entrega, consolidacao: c, motorista_nome: motorista, valor_aplicado: valor) + create(:consolidacao_motorista, consolidacao: c, motorista_nome: motorista) + c + end + + let(:periodo) { { inicio: '2026-06-01', fim: '2026-06-30' } } + + describe 'admin — /consolidacoes/totais' do + it 'exige login' do + get totais_consolidacoes_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia motorista (Pundit)' do + sign_in create(:motorista) + get totais_consolidacoes_path + expect(response).to redirect_to(root_path) + end + + it 'mostra o total do motorista no perĂ­odo' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + sign_in create(:gerente) + + get totais_consolidacoes_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Carlos') + end + + it 'abre a composição do motorista clicado' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') + sign_in create(:gerente) + + get totais_consolidacoes_path(periodo.merge(motorista: 'Carlos')) + + expect(response.body).to include('Composição de Carlos') + expect(response.body).to include('Fechamento Junho') + end + + it 'gera o PDF' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + sign_in create(:gerente) + + get totais_pdf_consolidacoes_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/pdf') + end + end + + describe 'motorista — /motorista/totais' do + it 'mostra sĂł o total dele e as consolidaçÔes que o compĂ”em' do + motorista = create(:motorista, nome: 'Carlos') + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') + consolidacao_finalizada(motorista: 'Pedro', valor: 999.0, nome: 'Fechamento do Pedro') + sign_in motorista + + get motorista_totais_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Fechamento Junho') + expect(response.body).not_to include('Fechamento do Pedro') + end + + it 'gera o PDF do perĂ­odo' do + motorista = create(:motorista, nome: 'Carlos') + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + sign_in motorista + + get motorista_totais_pdf_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/pdf') + end + + it 'nĂŁo Ă© acessĂ­vel por quem nĂŁo Ă© motorista' do + sign_in create(:gerente) + get motorista_totais_path + expect(response).to redirect_to(dashboard_path) + end + end +end diff --git a/spec/services/analytics/totais_por_motorista_spec.rb b/spec/services/analytics/totais_por_motorista_spec.rb new file mode 100644 index 0000000..7b2e245 --- /dev/null +++ b/spec/services/analytics/totais_por_motorista_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +# Trava a conta do relatĂłrio "total por motorista" — a mesma que alimenta +# /consolidacoes/totais (admin) e /motorista/totais (motorista). As duas telas +# usam ESTE service justamente para nĂŁo poderem divergir. +RSpec.describe Analytics::TotaisPorMotorista do + let(:inicio) { Date.new(2026, 6, 1) } + let(:fim) { Date.new(2026, 6, 30) } + + # Uma consolidação com um motorista e `qtd` lançamentos de `valor` cada. As + # entregas vĂȘm ANTES do ConsolidacaoMotorista porque Ă© o before_save dele que + # calcula valor_total a partir delas. + def consolidar(motorista:, valor: 100.0, qtd: 1, status: :finalizada, + data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30), + pago: false, arquivado: false) + c = create(:consolidacao, status: status, data_inicio: data_inicio, data_fim: data_fim) + qtd.times { create(:consolidacao_entrega, consolidacao: c, motorista_nome: motorista, valor_aplicado: valor) } + cm = create(:consolidacao_motorista, consolidacao: c, motorista_nome: motorista) + cm.update!(pago_em: Time.current, forma_pagamento: 'pix') if pago + cm.arquivar!(nil) if arquivado + [c, cm] + end + + subject(:totais) { described_class.new(inicio: inicio, fim: fim) } + + it 'soma por motorista sĂł as consolidaçÔes finalizadas do perĂ­odo' do + consolidar(motorista: 'Carlos', valor: 100.0, qtd: 2) # 200 + consolidar(motorista: 'Carlos', valor: 50.0, qtd: 1) # 50 + consolidar(motorista: 'Pedro', valor: 30.0, qtd: 1) # 30 + consolidar(motorista: 'Carlos', valor: 999.0, status: :rascunho) # fora (rascunho) + consolidar(motorista: 'Carlos', valor: 999.0, # fora (perĂ­odo) + data_inicio: Date.new(2026, 7, 1), data_fim: Date.new(2026, 7, 31)) + + expect(totais.linhas.map { |l| [l[:motorista], l[:valor_total]] }) + .to eq([['Carlos', 250.0], ['Pedro', 30.0]]) + expect(totais.total_geral).to eq(280.0) + expect(totais.total_consolidacoes).to eq(3) + end + + it 'inclui rascunhos quando pedido' do + consolidar(motorista: 'Carlos', valor: 100.0) + consolidar(motorista: 'Carlos', valor: 40.0, status: :rascunho) + + com_rascunho = described_class.new(inicio: inicio, fim: fim, incluir_rascunhos: true) + expect(totais.total_geral).to eq(100.0) + expect(com_rascunho.total_geral).to eq(140.0) + end + + it 'separa pago de a receber' do + consolidar(motorista: 'Carlos', valor: 100.0, pago: true) + consolidar(motorista: 'Carlos', valor: 60.0) + + linha = totais.linhas.first + expect(linha[:valor_pago]).to eq(100.0) + expect(linha[:valor_pendente]).to eq(60.0) + expect(totais.total_pago).to eq(100.0) + expect(totais.total_pendente).to eq(60.0) + end + + it 'ignora motorista arquivado (mesmo conjunto que a consolidação paga)' do + consolidar(motorista: 'Carlos', valor: 100.0) + consolidar(motorista: 'Carlos', valor: 70.0, arquivado: true) + + expect(totais.total_geral).to eq(100.0) + end + + it 'lista as consolidaçÔes que compĂ”em o total do motorista' do + c1, = consolidar(motorista: 'Carlos', valor: 100.0) + c2, = consolidar(motorista: 'Carlos', valor: 60.0, data_fim: Date.new(2026, 6, 15)) + consolidar(motorista: 'Pedro', valor: 30.0) + + composicao = totais.consolidacoes_de('Carlos') + expect(composicao.map(&:consolidacao_id)).to match_array([c1.id, c2.id]) + expect(composicao.sum(&:valor_total)).to eq(160.0) + end + + it 'recortado por motorista, sĂł enxerga o dele (visĂŁo do painel do motorista)' do + consolidar(motorista: 'Carlos', valor: 100.0) + consolidar(motorista: 'Pedro', valor: 30.0) + + meu = described_class.new(inicio: inicio, fim: fim, motorista: 'Carlos') + expect(meu.total_geral).to eq(100.0) + expect(meu.linhas.size).to eq(1) + end + + it 'perĂ­odo sem fechamento fica vazio (e nĂŁo quebra)' do + vazio = described_class.new(inicio: Date.new(2026, 1, 1), fim: Date.new(2026, 1, 31)) + expect(vazio).to be_vazio + expect(vazio.total_geral).to eq(0) + expect(vazio.linhas).to eq([]) + end +end -- 2.49.1 From 7a8ec1bc165e01e03e99ca3cdb08eaf1d116f8e8c9df2b96b3c795ca38906cac Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 15:15:44 -0300 Subject: [PATCH 13/40] =?UTF-8?q?Cria=C3=A7=C3=A3o=20da=20vis=C3=A3o=20ger?= =?UTF-8?q?al=20da=20consolida=C3=A7=C3=A3o=20por=20motorista=20e=20Estili?= =?UTF-8?q?za=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/consolidacoes_controller.rb | 62 +++--- .../motorista/dashboard_controller.rb | 130 ++++++------ .../analytics/totais_por_motorista.rb | 31 +-- app/views/consolidacoes/_lista.html.erb | 38 ++++ .../consolidacoes/_por_motorista.html.erb | 128 ++++++++++++ app/views/consolidacoes/index.html.erb | 92 ++++----- app/views/consolidacoes/totais.html.erb | 191 ------------------ app/views/layouts/_navbar.html.erb | 2 - app/views/motorista/dashboard/index.html.erb | 174 ++++++++++------ app/views/motorista/dashboard/totais.html.erb | 95 --------- config/routes.rb | 12 +- spec/requests/motorista_dashboard_spec.rb | 6 +- spec/requests/totais_por_motorista_spec.rb | 52 +++-- .../analytics/totais_por_motorista_spec.rb | 20 +- 14 files changed, 508 insertions(+), 525 deletions(-) create mode 100644 app/views/consolidacoes/_lista.html.erb create mode 100644 app/views/consolidacoes/_por_motorista.html.erb delete mode 100644 app/views/consolidacoes/totais.html.erb delete mode 100644 app/views/motorista/dashboard/totais.html.erb diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index 30a09fa..c988e79 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -13,10 +13,21 @@ class ConsolidacoesController < ApplicationController # Formas de pagamento aceitas (o select da UI usa esta mesma lista). FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze - # GET /consolidacoes — lista com filtros + # GET /consolidacoes?visao=lista|motoristas — a MESMA tela em dois modos de + # visualização, com os mesmos filtros: + # + # lista → um card por CONSOLIDAÇÃO (o de sempre); + # motoristas → um card por MOTORISTA, com o total dele no perĂ­odo e, ao + # clicar, as consolidaçÔes que compĂ”em aquele valor. + # + # SĂŁo duas leituras do mesmo recorte: o trabalho Ă© organizado por consolidação, + # mas o pagamento Ă© por motorista — antes, para saber quanto um motorista tinha + # fechado no mĂȘs, era preciso abrir consolidação por consolidação e somar Ă  mĂŁo. def index authorize Consolidacao + @visao = params[:visao] == 'motoristas' ? 'motoristas' : 'lista' + @consolidacoes = Consolidacao.ativas.recentes.includes(:criador) # Filtros @@ -38,42 +49,25 @@ class ConsolidacoesController < ApplicationController if %w[pago pendente parcial].include?(params[:pagamento]) @consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}") end + + montar_totais if @visao == 'motoristas' end - # GET /consolidacoes/totais?inicio=&fim=&motorista=&rascunhos=1 - # - # "Quanto cada motorista somou no perĂ­odo". A lista de consolidaçÔes mostra um - # card por CONSOLIDAÇÃO, mas o pagamento Ă© por MOTORISTA: para saber quanto um - # motorista fechou no mĂȘs era preciso abrir consolidação por consolidação e - # somar Ă  mĂŁo. Aqui o total vem pronto e, clicando no motorista (?motorista=), - # a tela abre as consolidaçÔes que compĂ”em aquele valor. - def totais - authorize Consolidacao, :index? - - @inicio, @fim = periodo_totais - @incluir_rascunhos = params[:rascunhos] == '1' - @motorista = params[:motorista].presence - @totais = Analytics::TotaisPorMotorista.new( - inicio: @inicio, fim: @fim, incluir_rascunhos: @incluir_rascunhos - ) - # Drill-down: as consolidaçÔes do motorista clicado (a tabela geral continua - # na tela, entĂŁo o service Ă© montado sem recorte de motorista). - @detalhe = @motorista ? @totais.consolidacoes_de(@motorista) : nil - end - - # GET /consolidacoes/totais_pdf — o mesmo relatĂłrio em PDF. Com ?motorista= - # sai sĂł o extrato daquele motorista (com espaço de assinatura). + # GET /consolidacoes/totais_pdf — o modo "Por motorista" em PDF, com os mesmos + # filtros da tela. Com ?detalhe=NOME sai sĂł o extrato daquele motorista (com + # espaço de assinatura). def totais_pdf authorize Consolidacao, :index? inicio, fim = periodo_totais totais = Analytics::TotaisPorMotorista.new( inicio: inicio, fim: fim, - motorista: params[:motorista].presence, - incluir_rascunhos: params[:rascunhos] == '1' + motorista: params[:detalhe].presence, + status: params[:status].presence || 'todos', + busca: params[:motorista].presence ) - nome = params[:motorista].presence&.parameterize(separator: '_') + nome = params[:detalhe].presence&.parameterize(separator: '_') arquivo = ['totais', nome, inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].compact.join('_') send_data Pdf::TotaisMotoristaPdf.new(totais: totais, detalhar: params[:detalhar] != '0').render, filename: "#{arquivo}.pdf", @@ -490,6 +484,20 @@ class ConsolidacoesController < ApplicationController private + # Modo "Por motorista": os totais saem do MESMO recorte da lista (perĂ­odo, + # status e busca por motorista), para as duas visĂ”es nunca contarem coisas + # diferentes. `detalhe` Ă© o motorista aberto (o clique na linha). + def montar_totais + @inicio, @fim = periodo_totais + @totais = Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, + status: params[:status].presence || 'todos', + busca: params[:motorista].presence + ) + @detalhe_motorista = params[:detalhe].presence + @detalhe = @detalhe_motorista ? @totais.consolidacoes_de(@detalhe_motorista) : nil + end + # PerĂ­odo do relatĂłrio de totais. Default: o MÊS CORRENTE INTEIRO — o fim Ă© o # Ășltimo dia do mĂȘs, nĂŁo "hoje", porque o recorte exige a consolidação inteira # dentro da faixa (Consolidacao.no_periodo) e um fechamento que vai atĂ© 31/08 diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 86bb26c..828a024 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -3,47 +3,24 @@ module Motorista class DashboardController < ApplicationController before_action :garantir_motorista - # GET /motorista + # GET /motorista?inicio=&fim= + # + # UMA tela sĂł: o motorista escolhe o perĂ­odo e vĂȘ, para aquele recorte, o que + # jĂĄ foi FECHADO pela empresa, o que ainda Ă© ESTIMATIVA e de onde vem cada + # centavo (os fechamentos que compĂ”em o total). Antes o total ficava numa + # tela e a composição em outra — e nenhuma das duas tinha perĂ­odo. def index nome = current_user.nome - # Card 1 — Valor ESTIMADO (mĂȘs atual): entregas ATENDIDAS × preco_entrega. - # - # Atendida = o motorista FOI ao local e fechou a visita, com sucesso - # ('completed') OU insucesso ('failed') — no insucesso o deslocamento - # aconteceu e teve custo, entĂŁo a empresa paga os dois. É o MESMO recorte do - # dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e - # Entrega.contar_atendidas): usava `pagas` (sĂł concluĂ­das) e o motorista via - # um valor MENOR que o do fechamento, sem entender de onde vinha a diferença. - # - # ⚠ O eixo Ă© o CHECKOUT (data real da visita), nĂŁo planned_date: falhada TEM - # checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a - # agosto — igual ao que a consolidação considera elegĂ­vel. - @estimado_inicio = Date.current.beginning_of_month - @estimado_fim = Date.current - atendidas = Entrega.atendidas.da_conta_gade - .do_motorista(nome) - .no_periodo_checkout(@estimado_inicio, @estimado_fim) - @entregas_concluidas = atendidas.concluidas.count - @entregas_falhadas = atendidas.falhadas.count - @entregas_mes = @entregas_concluidas + @entregas_falhadas - @valor_estimado = @entregas_mes * Configuracao.preco_entrega + @inicio, @fim = periodo_selecionado + @faixa_total = faixa_completa(nome) # para o atalho "Tudo" - # Card 2 — Valor CONSOLIDADO: soma das consolidaçÔes finalizadas - @minhas_consolidacoes = Consolidacao.ativas - .joins(:consolidacao_motoristas) - .where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil }) - .recentes - .distinct - - @valor_consolidado = ConsolidacaoMotorista.ativos - .joins(:consolidacao) - .where(motorista_nome: nome, - consolidacoes: { status: :finalizada, deleted_at: nil }) - .sum(:valor_total) + carregar_estimado(nome) + carregar_fechado(nome) # Aviso in-app: pagamentos confirmados nos Ășltimos 7 dias (notificação na - # prĂłpria pĂĄgina do motorista, alĂ©m de WhatsApp/email). + # prĂłpria pĂĄgina do motorista, alĂ©m de WhatsApp/email). Independe do + # perĂ­odo escolhido — Ă© um aviso, nĂŁo um relatĂłrio. @pagamentos_recentes = ConsolidacaoMotorista .joins(:consolidacao) .where(motorista_nome: nome, @@ -54,23 +31,11 @@ module Motorista .includes(:consolidacao) end - # GET /motorista/totais?inicio=&fim= - # - # "Quanto eu fechei neste perĂ­odo e de onde vem esse valor". O card do painel - # mostra sĂł o total acumulado; aqui o motorista escolhe o perĂ­odo, vĂȘ o total - # FECHADO (consolidaçÔes finalizadas) e a lista de consolidaçÔes que compĂ”em - # o valor — a mesma conta que o admin vĂȘ em /consolidacoes/totais, montada - # pelo mesmo service para os dois nĂșmeros nĂŁo poderem divergir. - def totais - @inicio, @fim = periodo_totais - @totais = totais_do_periodo - @consolidacoes = @totais.consolidacoes_de(current_user.nome) - end - - # GET /motorista/totais/pdf — o extrato do perĂ­odo em PDF (com assinatura), - # para o motorista guardar ou mandar por WhatsApp. + # GET /motorista/totais/pdf?inicio=&fim= — o extrato do perĂ­odo (total, + # composição e assinaturas), para guardar ou mandar por WhatsApp. Mesmo + # service da tela, entĂŁo o papel e o painel nunca divergem. def totais_pdf - inicio, fim = periodo_totais + inicio, fim = periodo_selecionado totais = Analytics::TotaisPorMotorista.new( inicio: inicio, fim: fim, motorista: current_user.nome ) @@ -85,16 +50,65 @@ module Motorista private - def totais_do_periodo - Analytics::TotaisPorMotorista.new( - inicio: @inicio, fim: @fim, motorista: current_user.nome - ) + # Card ESTIMADO: entregas ATENDIDAS × preco_entrega dentro do perĂ­odo. + # + # Atendida = o motorista FOI ao local e fechou a visita, com sucesso + # ('completed') OU insucesso ('failed') — no insucesso o deslocamento + # aconteceu e teve custo, entĂŁo a empresa paga os dois. É o MESMO recorte do + # dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e + # Entrega.contar_atendidas): usava `pagas` (sĂł concluĂ­das) e o motorista via + # um valor MENOR que o do fechamento, sem entender de onde vinha a diferença. + # + # ⚠ O eixo Ă© o CHECKOUT (data real da visita), nĂŁo planned_date: falhada TEM + # checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a + # agosto — igual ao que a consolidação considera elegĂ­vel. + # + # O fim Ă© limitado a HOJE: estimar entregas de dias que ainda nĂŁo + # aconteceram nĂŁo faz sentido (o perĂ­odo pode ir atĂ© o fim do mĂȘs). + def carregar_estimado(nome) + @estimado_inicio = @inicio + @estimado_fim = [@fim, Date.current].min + + if @estimado_fim < @estimado_inicio + @entregas_concluidas = @entregas_falhadas = @entregas_mes = 0 + @valor_estimado = 0 + return + end + + atendidas = Entrega.atendidas.da_conta_gade + .do_motorista(nome) + .no_periodo_checkout(@estimado_inicio, @estimado_fim) + @entregas_concluidas = atendidas.concluidas.count + @entregas_falhadas = atendidas.falhadas.count + @entregas_mes = @entregas_concluidas + @entregas_falhadas + @valor_estimado = @entregas_mes * Configuracao.preco_entrega end - # Default: mĂȘs corrente INTEIRO (fim = Ășltimo dia do mĂȘs). O recorte pede a + # Card FECHADO + composição: o mesmo service do modo "Por motorista" do + # admin, recortado neste motorista — os dois lados mostram o mesmo nĂșmero. + def carregar_fechado(nome) + @totais = Analytics::TotaisPorMotorista.new(inicio: @inicio, fim: @fim, motorista: nome) + @consolidacoes = @totais.consolidacoes_de(nome) + @valor_consolidado = @totais.total_geral + end + + # Primeira e Ășltima data de fechamento do motorista — alimenta o atalho + # "Tudo" com datas reais em vez de uma faixa inventada. + def faixa_completa(nome) + escopo = Consolidacao.ativas.finalizada + .joins(:consolidacao_motoristas) + .where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil }) + inicio = escopo.minimum(:data_inicio) + fim = escopo.maximum(:data_fim) + return nil if inicio.nil? || fim.nil? + + [inicio.to_date, fim.to_date] + end + + # Default: mĂȘs corrente INTEIRO (fim = Ășltimo dia do mĂȘs). O recorte exige a # consolidação inteira dentro da faixa, entĂŁo terminar em "hoje" esconderia - # um fechamento que vai atĂ© o fim do mĂȘs. Mesma regra do relatĂłrio do admin. - def periodo_totais + # um fechamento que vai atĂ© o fim do mĂȘs. Mesma regra do lado do admin. + def periodo_selecionado inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month fim = parse_data(params[:fim]) || Date.current.end_of_month inicio, fim = fim, inicio if fim < inicio diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb index 3112401..dc1b1ec 100644 --- a/app/services/analytics/totais_por_motorista.rb +++ b/app/services/analytics/totais_por_motorista.rb @@ -3,8 +3,9 @@ # Quanto cada motorista somou no perĂ­odo — e QUAIS consolidaçÔes formam esse # total. É a fonte Ășnica das duas telas de "total por motorista": # -# ‱ admin → /consolidacoes/totais (todos os motoristas + drill-down) -# ‱ motorista→ /motorista/totais (sĂł ele, mesma conta, mesmo PDF) +# ‱ admin → /consolidacoes?visao=motoristas (modo de visualização da prĂłpria +# lista, com drill-down no motorista clicado) +# ‱ motorista→ /motorista (o painel, jĂĄ recortado nele — mesma conta, mesmo PDF) # # RECORTE (igual ao filtro da lista de consolidaçÔes): entram as consolidaçÔes # ATIVAS cujo perĂ­odo estĂĄ DENTRO da faixa escolhida (data_inicio >= inicio e @@ -17,30 +18,36 @@ # bate com a soma dos fechamentos. module Analytics class TotaisPorMotorista - def initialize(inicio:, fim:, motorista: nil, incluir_rascunhos: false) - @inicio = inicio.to_date - @fim = fim.to_date - @motorista = motorista.presence - @incluir_rascunhos = incluir_rascunhos + # status: 'finalizada' (padrĂŁo — sĂł o que jĂĄ fechou), 'rascunho' ou + # 'todos'. É o MESMO select de status da lista de consolidaçÔes. + # motorista: recorte exato (drill-down / painel do motorista). + # busca: filtro parcial por nome, o campo de busca da lista. + def initialize(inicio:, fim:, motorista: nil, status: 'finalizada', busca: nil) + @inicio = inicio.to_date + @fim = fim.to_date + @motorista = motorista.presence + @status = status.presence || 'finalizada' + @busca = busca.presence end - attr_reader :inicio, :fim, :motorista + attr_reader :inicio, :fim, :motorista, :status # ConsolidacaoMotorista do recorte, com a consolidação jĂĄ carregada (as duas # telas mostram nome/perĂ­odo/status dela em seguida). def registros @registros ||= begin base = Consolidacao.ativas.no_periodo(@inicio, @fim) - # SĂł o que jĂĄ foi FECHADO conta como total do perĂ­odo; rascunho ainda - # muda de valor. `merge` (e nĂŁo where(consolidacoes: {status: ...})) - # porque status Ă© enum: quem sabe traduzir :finalizada Ă© o prĂłprio model. - base = base.finalizada unless @incluir_rascunhos + # Por padrĂŁo sĂł o que jĂĄ foi FECHADO conta como total do perĂ­odo — + # rascunho ainda muda de valor. `merge` (e nĂŁo where(consolidacoes: + # {status: ...})) porque status Ă© enum: quem traduz :finalizada Ă© o model. + base = base.public_send(@status) if %w[finalizada rascunho].include?(@status) escopo = ConsolidacaoMotorista.ativos .joins(:consolidacao) .merge(base) .includes(:consolidacao) escopo = escopo.where(motorista_nome: @motorista) if @motorista + escopo = escopo.where('motorista_nome ILIKE ?', "%#{@busca}%") if @busca escopo.to_a end end diff --git a/app/views/consolidacoes/_lista.html.erb b/app/views/consolidacoes/_lista.html.erb new file mode 100644 index 0000000..a4c4307 --- /dev/null +++ b/app/views/consolidacoes/_lista.html.erb @@ -0,0 +1,38 @@ +<%# app/views/consolidacoes/_lista.html.erb — visĂŁo por CONSOLIDAÇÃO %> +<% if @consolidacoes.any? %> +
+ <% @consolidacoes.each do |c| %> + <%= link_to consolidacao_path(c), + class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %> +
+
+
+

<%= c.nome %>

+ <%= badge_status(c.status) %> + <% if c.finalizada? %><%= badge_pagamento(c.status_pagamento) %><% end %> +
+

+ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> + · <%= icone :pessoa %> <%= c.consolidacao_motoristas.count %> motorista(s) + · Criada por <%= c.criador.nome_display %> +

+ <% if c.operacoes.present? %> +

<%= icone :pasta %> <%= c.operacoes_label.join(' · ') %>

+ <% end %> +
+
+

<%= moeda(c.valor_total) %>

+

<%= c.consolidacao_entregas.count %> entregas classificadas

+
+
+ <% end %> + <% end %> +
+<% else %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhuma consolidação encontrada.

+ <%= link_to 'Criar primeira consolidação', new_consolidacao_path, + class: 'inline-block bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg' %> +
+<% end %> diff --git a/app/views/consolidacoes/_por_motorista.html.erb b/app/views/consolidacoes/_por_motorista.html.erb new file mode 100644 index 0000000..27408cd --- /dev/null +++ b/app/views/consolidacoes/_por_motorista.html.erb @@ -0,0 +1,128 @@ +<%# app/views/consolidacoes/_por_motorista.html.erb — visĂŁo por MOTORISTA + Mesmo recorte da lista, somado por quem recebe. Clicar num motorista abre, + ali mesmo (sem sair da tela), as consolidaçÔes que compĂ”em o valor dele. %> + +<%# Link desta mesma tela preservando filtros — usado pelas linhas e pelos PDFs. %> +<% ctx = { nome: params[:nome], motorista: params[:motorista], status: params[:status], + inicio: @inicio.strftime('%Y-%m-%d'), fim: @fim.strftime('%Y-%m-%d'), + visao: 'motoristas' }.compact_blank %> + +<%# ── Resumo do perĂ­odo ────────────────────────────────── %> +
+
+

<%= icone :dinheiro %> Total do perĂ­odo

+

<%= moeda(@totais.total_geral) %>

+

<%= @totais.total_consolidacoes %> consolidação(Ôes) · <%= @totais.linhas.size %> motorista(s)

+
+
+

<%= icone :sucesso, cor: 'text-green-400' %> Pago

+

<%= moeda(@totais.total_pago) %>

+
+
+

<%= icone :parcial, cor: 'text-amber-400' %> A pagar

+

<%= moeda(@totais.total_pendente) %>

+
+
+

<%= icone :documento %> RelatĂłrio

+
+ <%= link_to rotulo(:baixar, 'PDF completo', cor: nil), + totais_pdf_consolidacoes_path(ctx.except(:visao)), + data: { turbo: false }, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold text-sm px-3 py-2 rounded-lg flex items-center justify-center' %> + <%= link_to 'SĂł o resumo', + totais_pdf_consolidacoes_path(ctx.except(:visao).merge(detalhar: '0')), + data: { turbo: false }, + class: 'text-gray-400 hover:text-orange-500 text-xs underline text-center' %> +
+
+
+ +<% if @totais.vazio? %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhuma consolidação com período dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %>.

+

Ajuste as datas acima — o recorte pega a consolidação inteira dentro da faixa.

+
+<% else %> +
+ <% @totais.linhas.each do |linha| %> + <% aberto = @detalhe_motorista == linha[:motorista] %> +
+ + <%# Cabeçalho do motorista — o clique abre/fecha a composição %> + <%= link_to consolidacoes_path(ctx.merge(detalhe: (aberto ? nil : linha[:motorista])).compact_blank), + class: 'block p-5' do %> +
+
+
+ <%= icone(aberto ? :expandir : :avancar, cor: 'text-gray-500', espaco: false) %> +

<%= linha[:motorista] %>

+
+

+ <%= icone :consolidacoes %> <%= linha[:consolidacoes] %> consolidação(Ôes) + · <%= icone :caminhao %> <%= linha[:lancamentos] %> lançamentos + <% if linha[:pagas].positive? %>· <%= linha[:pagas] %> paga(s)<% end %> + <% if linha[:pendentes].positive? %>· <%= linha[:pendentes] %> a pagar<% end %> +

+
+
+

<%= moeda(linha[:valor_total]) %>

+

+ <%= moeda(linha[:valor_pago]) %> pago + · + <%= moeda(linha[:valor_pendente]) %> a pagar +

+
+
+ <% end %> + + <%# Composição — o que forma o valor daquele motorista %> + <% if aberto %> +
+
+

+ <%= icone :composicao, espaco: false %> O que forma esse valor +

+ <%= link_to rotulo(:baixar, 'PDF deste motorista', cor: nil), + totais_pdf_consolidacoes_path(ctx.except(:visao).merge(detalhe: linha[:motorista])), + data: { turbo: false }, + class: 'text-gray-300 hover:text-orange-500 text-xs border border-[#2a2a2a] hover:border-orange-500 rounded-lg px-3 py-2 flex items-center' %> +
+ +
+ <% @detalhe.each do |cm| %> + <% c = cm.consolidacao %> + <%= link_to consolidacao_path(c), + class: 'flex flex-col md:flex-row md:items-center md:justify-between gap-2 bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-lg px-4 py-3 transition-colors' do %> +
+
+ <%= c.nome %> + <%= badge_status(c.status) %> + <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> +
+

+ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> + <% if cm.pago? %> + · <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> + <% if cm.nota_fiscal.present? %>· NF <%= cm.nota_fiscal %><% end %> + <% end %> +

+
+

<%= moeda(cm.valor_total) %>

+ <% end %> + <% end %> +
+
+ <% end %> +
+ <% end %> +
+ +

+ <%= icone :dica %> Entram as consolidaçÔes + <%= params[:status].presence ? "com status #{params[:status]}" : 'de qualquer status' %> + cujo perĂ­odo estĂĄ dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %>. + Motoristas arquivados na consolidação nĂŁo entram no total. +

+<% end %> diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index e8eaf82..0dfb198 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -6,8 +6,6 @@

<%= icone :consolidacoes, espaco: false %> ConsolidaçÔes

- <%= link_to rotulo(:motorista, 'Totais por motorista', cor: nil), totais_consolidacoes_path, - class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> <%= link_to rotulo(:arquivadas, 'Arquivadas', cor: nil), arquivadas_consolidacoes_path, class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> <% if policy(Consolidacao).create? %> @@ -72,6 +70,25 @@ <% end %>
+<%# ── Modo de visualização ────────────────────────────── + A mesma tela lida de dois jeitos: por CONSOLIDAÇÃO (como o trabalho Ă© + organizado) e por MOTORISTA (como o pagamento sai). Os filtros abaixo valem + para os dois — por isso eles viajam junto no link da aba. %> +<% ctx_filtros = { nome: params[:nome], motorista: params[:motorista], status: params[:status], + pagamento: params[:pagamento], vehicle: params[:vehicle], + inicio: params[:inicio], fim: params[:fim] }.compact_blank %> +
+ <% [['lista', :consolidacoes, 'ConsolidaçÔes'], ['motoristas', :motorista, 'Por motorista']].each do |modo, ic, label| %> + <% ativo = @visao == modo %> + <%= link_to rotulo(ic, label, cor: (ativo ? nil : 'text-brand-laranja')), + consolidacoes_path(ctx_filtros.merge(visao: modo)), + class: "px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-[#2a2a2a] hover:border-orange-500'}" %> + <% end %> + +
+ <%# ── 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 @@ -82,66 +99,49 @@ tamanho legĂ­vel e a linha quebra sozinha conforme o espaço REAL. %> <%= form_with url: consolidacoes_path, method: :get, 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
', - 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' %> + <%# O modo de visualização Ă© mantido ao filtrar (senĂŁo filtrar jogava o + operador de volta para a lista). %> + <%= hidden_field_tag :visao, @visao %> + + <% if @visao == 'motoristas' %> + <%= f.text_field :motorista, value: params[:motorista], placeholder: 'Buscar motorista
', + 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' %> + <% else %> + <%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome
', + 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' %> + <% end %> <%= f.select :status, options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]), {}, 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, - options_for_select([['Pagamento (todos)', ''], ['Pagas', 'pago'], ['Parciais', 'parcial'], ['Pendentes', 'pendente']], params[:pagamento]), - {}, class: 'flex-1 basis-44 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <%# "Pagamento" Ă© status DA CONSOLIDAÇÃO (todos os motoristas dela); no modo + Por motorista cada linha jĂĄ mostra pago/a receber, entĂŁo o filtro sairia + confuso — some. %> + <% if @visao != 'motoristas' %> + <%= f.select :pagamento, + options_for_select([['Pagamento (todos)', ''], ['Pagas', 'pago'], ['Parciais', 'parcial'], ['Pendentes', 'pendente']], params[:pagamento]), + {}, class: 'flex-1 basis-44 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% end %> - <%= f.date_field :inicio, value: params[:inicio], + <%# No modo Por motorista o perĂ­odo SEMPRE vale (default: mĂȘs corrente + inteiro), entĂŁo os campos jĂĄ vĂȘm preenchidos com o que estĂĄ sendo somado. %> + <%= f.date_field :inicio, value: (@visao == 'motoristas' ? @inicio.strftime('%Y-%m-%d') : params[:inicio]), 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: (@visao == 'motoristas' ? @fim.strftime('%Y-%m-%d') : params[:fim]), 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' %> <%# shrink-0 + whitespace-nowrap: os botĂ”es nunca sĂŁo espremidos a ponto de cortar o texto — se faltar espaço, a linha quebra e eles descem inteiros. %>
<%= 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' %> + <%= link_to 'Limpar', consolidacoes_path(visao: @visao), class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %>
<% end %> -<%# ── Lista ────────────────────────────────────────────── %> -<% if @consolidacoes.any? %> -
- <% @consolidacoes.each do |c| %> - <%= link_to consolidacao_path(c), - class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %> -
-
-
-

<%= c.nome %>

- <%= badge_status(c.status) %> - <% if c.finalizada? %><%= badge_pagamento(c.status_pagamento) %><% end %> -
-

- <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> - · <%= icone :pessoa %> <%= c.consolidacao_motoristas.count %> motorista(s) - · Criada por <%= c.criador.nome_display %> -

- <% if c.operacoes.present? %> -

<%= icone :pasta %> <%= c.operacoes_label.join(' · ') %>

- <% end %> -
-
-

<%= moeda(c.valor_total) %>

-

<%= c.consolidacao_entregas.count %> entregas classificadas

-
-
- <% end %> - <% end %> -
+<% if @visao == 'motoristas' %> + <%= render 'por_motorista' %> <% else %> -
-

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

-

Nenhuma consolidação encontrada.

- <%= link_to 'Criar primeira consolidação', new_consolidacao_path, - class: 'inline-block bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg' %> -
+ <%= render 'lista' %> <% end %> diff --git a/app/views/consolidacoes/totais.html.erb b/app/views/consolidacoes/totais.html.erb deleted file mode 100644 index 2e5ad16..0000000 --- a/app/views/consolidacoes/totais.html.erb +++ /dev/null @@ -1,191 +0,0 @@ -<%# app/views/consolidacoes/totais.html.erb — Total por motorista no período %> -<% content_for :title, 'Totais por motorista' %> - -
-
-

<%= icone :motorista, espaco: false %> Totais por motorista

-

- ConsolidaçÔes <%= @incluir_rascunhos ? 'de qualquer status' : 'finalizadas' %> - com perĂ­odo dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %> -

-
-
- <%= link_to rotulo(:consolidacoes, 'ConsolidaçÔes', cor: nil), consolidacoes_path, - class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> - <%= link_to rotulo(:baixar, 'Baixar relatório (PDF)', cor: nil), - totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), motorista: @motorista), - data: { turbo: false }, - class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> -
-
- -<%# ── Filtros ──────────────────────────────────────────── %> -<%= form_with url: totais_consolidacoes_path, method: :get, - class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 flex flex-wrap items-center gap-3' do |f| %> - <%# O motorista clicado viaja junto para o filtro não fechar o drill-down. %> - <%= hidden_field_tag :motorista, @motorista if @motorista %> - - <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), - 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: @fim.strftime('%Y-%m-%d'), - 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.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', totais_consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %> -
-<% end %> - -<%# ── KPIs do período ──────────────────────────────────── %> -
-
-

<%= icone :dinheiro %> Total do perĂ­odo

-

<%= moeda(@totais.total_geral) %>

-

<%= @totais.total_consolidacoes %> consolidação(Ôes) · <%= @totais.linhas.size %> motorista(s)

-
-
-

<%= icone :sucesso, cor: 'text-green-400' %> Pago

-

<%= moeda(@totais.total_pago) %>

-
-
-

<%= icone :parcial, cor: 'text-amber-400' %> A pagar

-

<%= moeda(@totais.total_pendente) %>

-
-
-

<%= icone :ticket %> Média por motorista

-

- <%= moeda(@totais.linhas.any? ? @totais.total_geral / @totais.linhas.size : 0) %> -

-
-
- -<% if @totais.vazio? %> -
-

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

-

Nenhuma consolidação <%= @incluir_rascunhos ? '' : 'finalizada ' %>com período dentro dessa faixa.

-

Amplie o perĂ­odo ou marque "Incluir rascunhos".

-
-<% else %> - <%# ── Tabela: total por motorista (clique abre a composição) ── %> -
-
- - - - - - - - - - - - - <% @totais.linhas.each do |l| %> - <% aberto = @motorista == l[:motorista] %> - - <%# O nome é um LINK de verdade (e não um ): abre/fecha - a composição daquele motorista e continua acessível pelo teclado. %> - - - - - - - - <% end %> - - - - - - - - - - - -
MotoristaConsolidaçÔesLançamentosPagoA pagarTotal
- <%= link_to totais_consolidacoes_path(inicio: @inicio, fim: @fim, - rascunhos: (@incluir_rascunhos ? '1' : nil), - motorista: (aberto ? nil : l[:motorista])), - class: 'text-white font-semibold hover:text-orange-500 flex items-center gap-1' do %> - <%= icone(aberto ? :expandir : :avancar, cor: 'text-gray-500', espaco: false) %><%= l[:motorista] %> - <% end %> - <%= l[:consolidacoes] %><%= l[:lancamentos] %><%= moeda(l[:valor_pago]) %><%= moeda(l[:valor_pendente]) %><%= moeda(l[:valor_total]) %>
TOTAL<%= @totais.total_consolidacoes %><%= moeda(@totais.total_pago) %><%= moeda(@totais.total_pendente) %><%= moeda(@totais.total_geral) %>
-
-
-

- <%= icone :dica %> Clique num motorista para ver as consolidaçÔes que compÔem o total dele. -

- <%# PDF sem a composição de cada motorista — para períodos com muita gente, - quando só o resumo interessa. %> - <%= link_to 'Baixar só o resumo (PDF)', - totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), detalhar: '0'), - data: { turbo: false }, - class: 'text-gray-400 hover:text-orange-500 text-xs underline whitespace-nowrap' %> -
-
- - <%# ── Drill-down: as consolidaçÔes que compĂ”em o total ── %> - <% if @detalhe %> -
-
-
-

- <%= icone :composicao, espaco: false %> Composição de <%= @motorista %> -

-

- <%= @detalhe.size %> consolidação(Ôes) no período · - <%= moeda(@detalhe.sum { |cm| cm.valor_total || 0 }) %> -

-
-
- <%= link_to rotulo(:baixar, 'PDF deste motorista', cor: nil), - totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), motorista: @motorista), - data: { turbo: false }, - class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-2.5 rounded-lg flex items-center' %> - <%= link_to rotulo(:fechar, 'Fechar', cor: nil), - totais_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil)), - class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-2.5 rounded-lg flex items-center' %> -
-
- - <% if @detalhe.empty? %> -

Nenhuma consolidação desse motorista neste período.

- <% end %> - -
- <% @detalhe.each do |cm| %> - <% c = cm.consolidacao %> - <%= link_to consolidacao_path(c), - class: 'block bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-4 transition-colors' do %> -
-
-
- <%= c.nome %> - <%= badge_status(c.status) %> - <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> -
-

- <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> - <% if cm.pago? %> - · <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> - <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> - <% if cm.nota_fiscal.present? %>· NF <%= cm.nota_fiscal %><% end %> - <% end %> -

-
-

<%= moeda(cm.valor_total) %>

-
- <% end %> - <% end %> -
-
- <% end %> -<% end %> diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 3a2e6f3..5e6e8f7 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -43,14 +43,12 @@ <%# ConsolidaçÔes — sĂł quem pode consolidar (exclui o externo) %> <% if current_user.pode_consolidar? %> <%= nav_link_to 'ConsolidaçÔes', consolidacoes_path, icon: :consolidacoes %> - <%= nav_link_to 'Totais por motorista', totais_consolidacoes_path, icon: :motorista %> <%= nav_link_to 'Arquivadas', arquivadas_consolidacoes_path, icon: :arquivadas %> <% end %> <%# Motorista %> <% if current_user.motorista? %> <%= nav_link_to 'Meu Painel', motorista_dashboard_path, icon: :painel %> - <%= nav_link_to 'Meu total do perĂ­odo', motorista_totais_path, icon: :dinheiro %> <% end %> <%# Admin/Gerente — ConfiguraçÔes %> diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index bdde49c..5aae57c 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -1,14 +1,23 @@ -<%# app/views/motorista/dashboard/index.html.erb — Painel do Motorista %> +<%# app/views/motorista/dashboard/index.html.erb — Painel do Motorista + Uma tela sĂł: perĂ­odo → quanto jĂĄ estĂĄ FECHADO, quanto ainda Ă© ESTIMATIVA e + de onde vem cada valor. Feita para celular: alvos grandes, nĂșmero em + destaque, sem tabela que precise de zoom. %> <% content_for :title, 'Meu Painel' %> +<% mes = Date.current.beginning_of_month..Date.current.end_of_month %> +<% mes_ant = (Date.current - 1.month).beginning_of_month..(Date.current - 1.month).end_of_month %> +<% tres_meses = (Date.current - 2.months).beginning_of_month..Date.current.end_of_month %> +<% atalhos = { 'Este mĂȘs' => mes, 'MĂȘs passado' => mes_ant, '3 meses' => tres_meses } %> +<% atalhos['Tudo'] = (@faixa_total.first..@faixa_total.last) if @faixa_total %> +

OlĂĄ, <%= current_user.nome.split.first %>!

-

Aqui estĂŁo seus valores de entregas.

+

Escolha o perĂ­odo e veja quanto vocĂȘ tem a receber.

<%# ── Aviso de pagamento recĂ©m-confirmado ───────────────── %> <% if @pagamentos_recentes.any? %> -
+

<%= icone :sucesso, cor: nil, espaco: false %> Pagamento confirmado!

<% @pagamentos_recentes.each do |cm| %> @@ -28,65 +37,112 @@
<% end %> - <%# ── 2 Cards grandes ─────────────────────────────────── %> -
- - <%# Card 1 — Valor Estimado (LARANJA) %> -
-

<%= icone :dinheiro, cor: nil, espaco: false %> Valor estimado deste mĂȘs

-

<%= icone :calendario, cor: nil %> <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %>

-

<%= moeda(@valor_estimado) %>

- <%# "Atendidas" = concluídas + sem sucesso. O rótulo NÃO pode voltar a dizer - "feitas e confirmadas": o valor acima inclui as visitas sem sucesso, que - a empresa também paga (o motorista foi ao local). Sem a segunda linha o - motorista não consegue conferir a conta. %> -

- <%= @entregas_mes %> entregas atendidas -

-

- <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) -

-

- <%= icone :alerta, cor: nil %> Valor aproximado. O valor certo sai quando a empresa fechar o pagamento. -

+ <%# ── Período ─────────────────────────────────────────── + Chips grandes para o caso comum e um "outro período" escondido no +
(sem JS) para a data exata. %> +
+
+ <% atalhos.each do |label, faixa| %> + <% ativo = @inicio == faixa.first && @fim == faixa.last %> + <%= link_to label, + motorista_dashboard_path(inicio: faixa.first.strftime('%Y-%m-%d'), fim: faixa.last.strftime('%Y-%m-%d')), + class: "px-4 py-3 rounded-xl text-base font-bold min-h-[48px] flex items-center #{ativo ? 'bg-orange-500 text-black' : 'bg-[#0a0a0a] text-gray-300 border border-[#2a2a2a]'}" %> + <% end %>
- <%# Card 2 — Valor Consolidado (BRANCO). ClicĂĄvel: leva ao total POR PERÍODO, - com as consolidaçÔes que compĂ”em o valor e o PDF do perĂ­odo. %> - <%= link_to motorista_totais_path, class: 'block bg-white rounded-2xl p-6 hover:ring-2 hover:ring-orange-500 transition' do %> -

<%= icone :sucesso, cor: 'text-green-600', espaco: false %> Valor fechado para pagamento

-

<%= moeda(@valor_consolidado) %>

-

- Soma dos pagamentos jĂĄ confirmados pela empresa -

-

- <%= icone :avancar, cor: 'text-orange-600' %> Ver por perĂ­odo e baixar o PDF +

> + <%= icone :calendario %> Outro perĂ­odo + <%= form_with url: motorista_dashboard_path, method: :get, class: 'flex flex-wrap items-end gap-3 mt-2' do |f| %> +
+ + <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> +
+
+ + <%= f.date_field :fim, value: @fim.strftime('%Y-%m-%d'), + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> +
+ <%= f.submit 'Ver', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl cursor-pointer px-6 py-3 min-h-[48px]' %> + <% end %> +
+
+ + <%# ── Card principal: o que já está FECHADO ────────────── %> +
+
+

+ <%= icone :sucesso, cor: 'text-green-600', espaco: false %> Fechado pela empresa

+ <%= l @inicio, format: :short %> a <%= l @fim, format: :short %> +
+ +

<%= moeda(@valor_consolidado) %>

+ + <%# As duas metades do total: o que jĂĄ caiu e o que ainda vem. %> +
+
+

<%= icone :dinheiro, cor: 'text-green-700' %> JĂĄ recebido

+

<%= moeda(@totais.total_pago) %>

+
+
+

<%= icone :parcial, cor: 'text-amber-700' %> A receber

+

<%= moeda(@totais.total_pendente) %>

+
+
+ +

+ <%= @consolidacoes.size %> fechamento(s) no perĂ­odo. +

+ + <% if @consolidacoes.any? %> + <%= link_to rotulo(:baixar, 'Baixar meu relatĂłrio do perĂ­odo (PDF)', cor: nil), + motorista_totais_pdf_path(inicio: @inicio, fim: @fim), + data: { turbo: false }, + class: 'w-full bg-orange-500 hover:bg-orange-600 text-black font-bold text-base px-6 py-4 rounded-xl min-h-[52px] flex items-center justify-center text-center mt-4' %> <% end %>
- <%# ── Lista de consolidaçÔes ──────────────────────────── %> -

<%= icone :planilha, espaco: false %> Meus pagamentos

+ <%# ── Card secundário: estimativa (ainda não fechada) ──── %> +
+
+

<%= icone :dinheiro, cor: nil, espaco: false %> Estimativa das entregas

+ <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> +
+

<%= moeda(@valor_estimado) %>

+ <%# "Atendidas" = concluídas + sem sucesso. O rótulo NÃO pode voltar a dizer + "feitas e confirmadas": o valor acima inclui as visitas sem sucesso, que + a empresa também paga (o motorista foi ao local). Sem a segunda linha o + motorista não consegue conferir a conta. %> +

<%= @entregas_mes %> entregas atendidas

+

+ <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) +

+

+ <%= icone :alerta, cor: nil %> Valor aproximado. O valor certo Ă© o de cima, quando a empresa fecha o pagamento. +

+
- <% if @minhas_consolidacoes.any? %> + <%# ── Composição: de onde vem o valor fechado ──────────── %> +

<%= icone :composicao, espaco: false %> De onde vem esse valor

+

Cada fechamento abaixo entra na soma do card branco.

+ + <% if @consolidacoes.any? %>
- <% @minhas_consolidacoes.each do |c| %> - <% cm = c.consolidacao_motoristas.find_by(motorista_nome: current_user.nome) %> + <% @consolidacoes.each do |cm| %> + <% c = cm.consolidacao %>
-
+

<%= c.nome %>

- <%= badge_status(c.status) %> - <% if c.finalizada? %> - <%= badge_pagamento(cm&.pago? ? :pago : :pendente) %> - <% end %> + <%= badge_pagamento(cm.pago? ? :pago : :pendente) %>

<%= icone :calendario %> <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %>

-

<%= moeda(cm&.valor_total || 0) %>

- <% if cm&.pago? %> +

<%= moeda(cm.valor_total) %>

+ <% if cm.pago? %>

<%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> @@ -94,18 +150,14 @@ <% if cm.nota_fiscal.present? %>

<%= icone :nota_fiscal %> Nota Fiscal: <%= cm.nota_fiscal %>

<% end %> + <% else %> +

<%= icone :parcial, cor: 'text-amber-400' %> Ainda nĂŁo pago

<% end %>
- <% if c.finalizada? %> - <%= link_to rotulo(:baixar, 'Baixar meu extrato', cor: nil), - gerar_pdf_extrato_consolidacao_path(c, motorista: current_user.nome), - data: { turbo: false }, - class: 'w-full md:w-auto bg-orange-500 hover:bg-orange-600 text-black font-bold text-base px-6 py-4 rounded-xl min-h-[52px] flex items-center justify-center text-center' %> - <% else %> - - <%= icone :parcial, cor: 'text-amber-400' %> Aguardando fechamento - - <% end %> + <%= link_to rotulo(:baixar, 'Extrato deste fechamento', cor: nil), + gerar_pdf_extrato_consolidacao_path(c, motorista: current_user.nome), + data: { turbo: false }, + class: 'w-full md:w-auto bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 text-white font-bold text-base px-5 py-4 rounded-xl min-h-[52px] flex items-center justify-center text-center' %>
<% end %> @@ -113,8 +165,14 @@ <% else %>

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

-

VocĂȘ ainda nĂŁo aparece em nenhum pagamento.

-

Quando a empresa fechar um perĂ­odo com suas entregas, ele aparece aqui.

+

Nenhum fechamento neste perĂ­odo.

+

+ <% if @faixa_total %> + Toque em Tudo para ver todos os seus fechamentos. + <% else %> + Quando a empresa fechar um perĂ­odo com suas entregas, ele aparece aqui. + <% end %> +

<% end %>
diff --git a/app/views/motorista/dashboard/totais.html.erb b/app/views/motorista/dashboard/totais.html.erb deleted file mode 100644 index c161ca1..0000000 --- a/app/views/motorista/dashboard/totais.html.erb +++ /dev/null @@ -1,95 +0,0 @@ -<%# app/views/motorista/dashboard/totais.html.erb — Meu total no período %> -<% content_for :title, 'Meu total no período' %> - -
- -
-

Meu total no perĂ­odo

- <%= link_to rotulo(:voltar, 'Voltar', cor: nil), motorista_dashboard_path, - class: 'text-gray-300 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-xl min-h-[48px] flex items-center' %> -
- - <%# ── Período ──────────────────────────────────────────── %> - <%= form_with url: motorista_totais_path, method: :get, - class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-4 mb-5 flex flex-wrap items-end gap-3' do |f| %> -
- - <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), - class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> -
-
- - <%= f.date_field :fim, value: @fim.strftime('%Y-%m-%d'), - class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> -
- <%= f.submit 'Ver', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl cursor-pointer px-6 py-3 min-h-[48px]' %> - <% end %> - - <%# ── Total fechado ────────────────────────────────────── %> -
-

- <%= icone :sucesso, cor: 'text-green-600', espaco: false %> Total fechado no perĂ­odo -

-

<%= icone :calendario, cor: 'text-gray-500' %> <%= l @inicio, format: :short %> a <%= l @fim, format: :short %>

-

<%= moeda(@totais.total_geral) %>

-
- <%= icone :dinheiro, cor: 'text-green-700' %> JĂĄ pago: <%= moeda(@totais.total_pago) %> - <%= icone :parcial, cor: 'text-amber-700' %> A receber: <%= moeda(@totais.total_pendente) %> -
-

- Soma de <%= @consolidacoes.size %> fechamento(s) da empresa neste perĂ­odo. -

-
- - <%= link_to rotulo(:baixar, 'Baixar relatĂłrio em PDF', cor: nil), - motorista_totais_pdf_path(inicio: @inicio, fim: @fim), - data: { turbo: false }, - class: 'w-full bg-orange-500 hover:bg-orange-600 text-black font-bold text-base px-6 py-4 rounded-xl min-h-[52px] flex items-center justify-center mb-6' %> - - <%# ── O que compĂ”e o valor ─────────────────────────────── %> -

<%= icone :composicao, espaco: false %> O que forma esse valor

-

Cada fechamento abaixo entra na soma acima.

- - <% if @consolidacoes.any? %> -
- <% @consolidacoes.each do |cm| %> - <% c = cm.consolidacao %> -
-
-
-
-

<%= c.nome %>

- <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> -
-

- <%= icone :calendario %> <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %> -

-

<%= moeda(cm.valor_total) %>

- <% if cm.pago? %> -

- <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> - <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> -

- <% if cm.nota_fiscal.present? %> -

<%= icone :nota_fiscal %> Nota Fiscal: <%= cm.nota_fiscal %>

- <% end %> - <% else %> -

<%= icone :parcial, cor: 'text-amber-400' %> Ainda nĂŁo pago

- <% end %> -
- <%= link_to rotulo(:baixar, 'Extrato deste fechamento', cor: nil), - gerar_pdf_extrato_consolidacao_path(c, motorista: current_user.nome), - data: { turbo: false }, - class: 'w-full md:w-auto bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 text-white font-bold text-base px-5 py-4 rounded-xl min-h-[52px] flex items-center justify-center text-center' %> -
-
- <% end %> -
- <% else %> -
-

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

-

Nenhum fechamento neste perĂ­odo.

-

Escolha outro período acima — o fechamento aparece aqui quando a empresa finaliza a consolidação.

-
- <% end %> -
diff --git a/config/routes.rb b/config/routes.rb index 4c90e09..3f28895 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,9 +27,8 @@ Rails.application.routes.draw do # Painel motorista get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard - # Total fechado do motorista num perĂ­odo + as consolidaçÔes que compĂ”em o - # valor (mesma conta do relatĂłrio do admin) e o PDF do extrato do perĂ­odo. - get '/motorista/totais', to: 'motorista/dashboard#totais', as: :motorista_totais + # Extrato do perĂ­odo do motorista em PDF (o painel jĂĄ mostra o total e as + # consolidaçÔes que compĂ”em o valor na prĂłpria tela). get '/motorista/totais/pdf', to: 'motorista/dashboard#totais_pdf', as: :motorista_totais_pdf # Login motorista — PIN de 4 dĂ­gitos + acesso via QR Code do extrato @@ -47,10 +46,9 @@ Rails.application.routes.draw do post :avulsa # cria consolidação avulsa a partir de uma NF get :arquivadas # HistĂłrico — consolidaçÔes arquivadas - # RelatĂłrio "total por motorista no perĂ­odo": soma o que cada motorista - # fechou na faixa e, ao clicar num deles (?motorista=), mostra as - # consolidaçÔes que compĂ”em o valor. `totais_pdf` Ă© o mesmo relatĂłrio em PDF. - get :totais + # PDF do modo de visualização "Por motorista" (/consolidacoes?visao= + # motoristas): o total de cada um no perĂ­odo e as consolidaçÔes que + # compĂ”em o valor. get :totais_pdf end diff --git a/spec/requests/motorista_dashboard_spec.rb b/spec/requests/motorista_dashboard_spec.rb index 7f1cce2..2970419 100644 --- a/spec/requests/motorista_dashboard_spec.rb +++ b/spec/requests/motorista_dashboard_spec.rb @@ -14,9 +14,9 @@ RSpec.describe 'Painel do motorista — contabilização das entregas sem sucess include ActiveSupport::Testing::TimeHelpers include_context 'espelho de rastreio' - # O painel nĂŁo aceita filtro de perĂ­odo (Ă© sempre "do dia 1Âș atĂ© hoje"), entĂŁo - # congelamos a data: sem isso o spec quebraria sozinho ao rodar no dia 1Âș, com - # as datas semeadas caindo fora do mĂȘs corrente. + # O painel abre no MÊS CORRENTE (a estimativa vai atĂ© hoje), entĂŁo congelamos a + # data: sem isso o spec quebraria sozinho ao rodar no dia 1Âș, com as datas + # semeadas caindo fora do mĂȘs corrente. let(:hoje) { Date.new(2026, 8, 20) } let(:preco) { 10.0 } let(:nome) { 'JOSE DA SILVA' } diff --git a/spec/requests/totais_por_motorista_spec.rb b/spec/requests/totais_por_motorista_spec.rb index 0f9b151..ed5d5be 100644 --- a/spec/requests/totais_por_motorista_spec.rb +++ b/spec/requests/totais_por_motorista_spec.rb @@ -1,8 +1,9 @@ require 'rails_helper' -# Rotas do relatĂłrio "total por motorista": quem entra, o que a tela mostra e o -# PDF. O drill-down (?motorista=) Ă© o que responde "de onde vem esse valor". -RSpec.describe 'Totais por motorista', type: :request do +# Modo de visualização "Por motorista" (/consolidacoes?visao=motoristas) e o +# painel do motorista: quem entra, o que a tela soma e o drill-down que responde +# "de onde vem esse valor". +RSpec.describe 'VisĂŁo por motorista', type: :request do def consolidacao_finalizada(motorista:, valor:, nome: 'Fechamento Junho') c = create(:consolidacao, nome: nome, status: :finalizada, data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30)) @@ -13,39 +14,49 @@ RSpec.describe 'Totais por motorista', type: :request do let(:periodo) { { inicio: '2026-06-01', fim: '2026-06-30' } } - describe 'admin — /consolidacoes/totais' do - it 'exige login' do - get totais_consolidacoes_path - expect(response).to redirect_to(new_user_session_path) - end - + describe 'admin — /consolidacoes?visao=motoristas' do it 'bloqueia motorista (Pundit)' do sign_in create(:motorista) - get totais_consolidacoes_path + get consolidacoes_path(visao: 'motoristas') expect(response).to redirect_to(root_path) end - it 'mostra o total do motorista no perĂ­odo' do + it 'soma o total do motorista no perĂ­odo' do consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) sign_in create(:gerente) - get totais_consolidacoes_path(periodo) + get consolidacoes_path(periodo.merge(visao: 'motoristas')) expect(response).to have_http_status(:ok) expect(response.body).to include('Carlos') + expect(response.body).to include('R$ 120,00') end - it 'abre a composição do motorista clicado' do + it 'sĂł abre a composição do motorista clicado' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') + consolidacao_finalizada(motorista: 'Pedro', valor: 90.0, nome: 'Fechamento do Pedro') + sign_in create(:gerente) + + get consolidacoes_path(periodo.merge(visao: 'motoristas')) + expect(response.body).not_to include('O que forma esse valor') + + get consolidacoes_path(periodo.merge(visao: 'motoristas', detalhe: 'Carlos')) + expect(response.body).to include('O que forma esse valor') + expect(response.body).to include('Fechamento Junho') + expect(response.body).not_to include('Fechamento do Pedro') + end + + it 'a visĂŁo lista continua sendo o padrĂŁo' do consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') sign_in create(:gerente) - get totais_consolidacoes_path(periodo.merge(motorista: 'Carlos')) + get consolidacoes_path - expect(response.body).to include('Composição de Carlos') expect(response.body).to include('Fechamento Junho') + expect(response.body).not_to include('Total do perĂ­odo') end - it 'gera o PDF' do + it 'gera o PDF do perĂ­odo' do consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) sign_in create(:gerente) @@ -56,17 +67,18 @@ RSpec.describe 'Totais por motorista', type: :request do end end - describe 'motorista — /motorista/totais' do - it 'mostra sĂł o total dele e as consolidaçÔes que o compĂ”em' do + describe 'motorista — painel' do + it 'mostra o total fechado do perĂ­odo e os fechamentos que o compĂ”em' do motorista = create(:motorista, nome: 'Carlos') consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') consolidacao_finalizada(motorista: 'Pedro', valor: 999.0, nome: 'Fechamento do Pedro') sign_in motorista - get motorista_totais_path(periodo) + get motorista_dashboard_path(periodo) expect(response).to have_http_status(:ok) expect(response.body).to include('Fechamento Junho') + expect(response.body).to include('R$ 120,00') expect(response.body).not_to include('Fechamento do Pedro') end @@ -83,7 +95,7 @@ RSpec.describe 'Totais por motorista', type: :request do it 'nĂŁo Ă© acessĂ­vel por quem nĂŁo Ă© motorista' do sign_in create(:gerente) - get motorista_totais_path + get motorista_totais_pdf_path(periodo) expect(response).to redirect_to(dashboard_path) end end diff --git a/spec/services/analytics/totais_por_motorista_spec.rb b/spec/services/analytics/totais_por_motorista_spec.rb index 7b2e245..155a2b0 100644 --- a/spec/services/analytics/totais_por_motorista_spec.rb +++ b/spec/services/analytics/totais_por_motorista_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' -# Trava a conta do relatĂłrio "total por motorista" — a mesma que alimenta -# /consolidacoes/totais (admin) e /motorista/totais (motorista). As duas telas +# Trava a conta do modo de visualização "Por motorista" — a mesma que alimenta +# /consolidacoes?visao=motoristas (admin) e o painel do motorista. As duas telas # usam ESTE service justamente para nĂŁo poderem divergir. RSpec.describe Analytics::TotaisPorMotorista do let(:inicio) { Date.new(2026, 6, 1) } @@ -37,13 +37,21 @@ RSpec.describe Analytics::TotaisPorMotorista do expect(totais.total_consolidacoes).to eq(3) end - it 'inclui rascunhos quando pedido' do + it 'respeita o filtro de status (o mesmo select da lista)' do consolidar(motorista: 'Carlos', valor: 100.0) consolidar(motorista: 'Carlos', valor: 40.0, status: :rascunho) - com_rascunho = described_class.new(inicio: inicio, fim: fim, incluir_rascunhos: true) - expect(totais.total_geral).to eq(100.0) - expect(com_rascunho.total_geral).to eq(140.0) + expect(totais.total_geral).to eq(100.0) # default: sĂł finalizadas + expect(described_class.new(inicio: inicio, fim: fim, status: 'todos').total_geral).to eq(140.0) + expect(described_class.new(inicio: inicio, fim: fim, status: 'rascunho').total_geral).to eq(40.0) + end + + it 'busca parcial por nome do motorista (campo de busca da tela)' do + consolidar(motorista: 'Carlos Matheus', valor: 100.0) + consolidar(motorista: 'Pedro Martins', valor: 30.0) + + achou = described_class.new(inicio: inicio, fim: fim, busca: 'matheus') + expect(achou.linhas.map { |l| l[:motorista] }).to eq(['Carlos Matheus']) end it 'separa pago de a receber' do -- 2.49.1 From 6c78570658197b6c523311e80fadc222e44e5f698e89d1ded8215e546b1ed03f Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 15:34:56 -0300 Subject: [PATCH 14/40] =?UTF-8?q?Modifica=C3=A7=C3=A3o=20na=20area=20do=20?= =?UTF-8?q?motorista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/consolidacoes_controller.rb | 33 +++++--- .../motorista/dashboard_controller.rb | 5 +- .../controllers/carrossel_controller.js | 68 ++++++++++++++++ app/models/consolidacao.rb | 10 +++ .../analytics/totais_por_motorista.rb | 39 +++++++--- .../consolidacoes/_por_motorista.html.erb | 23 ++++-- app/views/consolidacoes/index.html.erb | 17 +++- app/views/motorista/dashboard/index.html.erb | 77 ++++++++++++++----- spec/requests/totais_por_motorista_spec.rb | 18 +++++ .../analytics/totais_por_motorista_spec.rb | 25 +++++- 10 files changed, 261 insertions(+), 54 deletions(-) create mode 100644 app/javascript/controllers/carrossel_controller.js diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index c988e79..46cbaea 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -33,8 +33,17 @@ class ConsolidacoesController < ApplicationController # Filtros @consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present? @consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present? - @consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present? - @consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present? + # Datas: a consolidação entra se CRUZA a faixa — mesmo critĂ©rio do modo "Por + # motorista" e do dashboard financeiro. Com o critĂ©rio antigo (caber inteira + # dentro do filtro), um fechamento de 01/08 a 31/08 sumia da lista ao filtrar + # 01/08–26/08 e as duas abas da MESMA tela contavam coisas diferentes. + if params[:inicio].present? && params[:fim].present? + @consolidacoes = @consolidacoes.cruzando_periodo(params[:inicio], params[:fim]) + elsif params[:inicio].present? + @consolidacoes = @consolidacoes.where('data_fim >= ?', params[:inicio]) + elsif params[:fim].present? + @consolidacoes = @consolidacoes.where('data_inicio <= ?', params[:fim]) + end if params[:motorista].present? ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%") @@ -63,7 +72,7 @@ class ConsolidacoesController < ApplicationController totais = Analytics::TotaisPorMotorista.new( inicio: inicio, fim: fim, motorista: params[:detalhe].presence, - status: params[:status].presence || 'todos', + status: params[:status].presence || 'finalizada', busca: params[:motorista].presence ) @@ -484,24 +493,26 @@ class ConsolidacoesController < ApplicationController private - # Modo "Por motorista": os totais saem do MESMO recorte da lista (perĂ­odo, - # status e busca por motorista), para as duas visĂ”es nunca contarem coisas - # diferentes. `detalhe` Ă© o motorista aberto (o clique na linha). + # Modo "Por motorista": mesmo perĂ­odo/busca da lista, mas o recorte de datas e + # o default de status sĂŁo os do DASHBOARD FINANCEIRO — Ă© com ele que o operador + # compara o nĂșmero (ver Analytics::TotaisPorMotorista). Sem status na URL vale + # "Consolidado" (sĂł finalizadas): "Geral" (com rascunhos) mostrava um valor + # maior que o do dashboard e parecia erro de conta. + # `detalhe` Ă© o motorista aberto (o clique na linha). def montar_totais @inicio, @fim = periodo_totais + @status_totais = params[:status].presence || 'finalizada' @totais = Analytics::TotaisPorMotorista.new( inicio: @inicio, fim: @fim, - status: params[:status].presence || 'todos', + status: @status_totais, busca: params[:motorista].presence ) @detalhe_motorista = params[:detalhe].presence @detalhe = @detalhe_motorista ? @totais.consolidacoes_de(@detalhe_motorista) : nil end - # PerĂ­odo do relatĂłrio de totais. Default: o MÊS CORRENTE INTEIRO — o fim Ă© o - # Ășltimo dia do mĂȘs, nĂŁo "hoje", porque o recorte exige a consolidação inteira - # dentro da faixa (Consolidacao.no_periodo) e um fechamento que vai atĂ© 31/08 - # sumiria da tela atĂ© o dia 31 chegar. + # PerĂ­odo do relatĂłrio de totais. Default: o MÊS CORRENTE INTEIRO (o mĂȘs Ă© a + # unidade de fechamento; "atĂ© hoje" recortaria o mĂȘs pela metade sem motivo). def periodo_totais inicio = parse_data_totais(params[:inicio]) || Date.current.beginning_of_month fim = parse_data_totais(params[:fim]) || Date.current.end_of_month diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 828a024..474b083 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -105,9 +105,8 @@ module Motorista [inicio.to_date, fim.to_date] end - # Default: mĂȘs corrente INTEIRO (fim = Ășltimo dia do mĂȘs). O recorte exige a - # consolidação inteira dentro da faixa, entĂŁo terminar em "hoje" esconderia - # um fechamento que vai atĂ© o fim do mĂȘs. Mesma regra do lado do admin. + # Default: mĂȘs corrente INTEIRO (fim = Ășltimo dia do mĂȘs) — o mĂȘs Ă© a unidade + # de fechamento. Mesma regra do lado do admin. def periodo_selecionado inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month fim = parse_data(params[:fim]) || Date.current.end_of_month diff --git a/app/javascript/controllers/carrossel_controller.js b/app/javascript/controllers/carrossel_controller.js new file mode 100644 index 0000000..74e6dbd --- /dev/null +++ b/app/javascript/controllers/carrossel_controller.js @@ -0,0 +1,68 @@ +// app/javascript/controllers/carrossel_controller.js +// Stimulus — paginação em "carrossel" para listas longas no celular (painel do +// motorista). Os itens jĂĄ vĂȘm todos no HTML; aqui sĂł mostramos uma PÁGINA por +// vez, com setas e o indicador "1 / 3". +// +// Por que no cliente e nĂŁo com ?pg= no servidor: a lista divide a tela com os +// cards de valor e o filtro de perĂ­odo — recarregar a pĂĄgina a cada seta faria o +// motorista perder a posição e piscar a tela inteira num 3G. +// +// Sem itens suficientes para duas pĂĄginas, os controles somem sozinhos (nada de +// seta morta ocupando espaço). + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["item", "controles", "indicador", "anterior", "proximo"] + static values = { tamanho: { type: Number, default: 10 } } + + connect() { + this.pagina = 0 + this.render() + } + + proxima() { this.ir(this.pagina + 1) } + anterior() { this.ir(this.pagina - 1) } + + ir(destino) { + this.pagina = Math.min(Math.max(destino, 0), this.totalPaginas - 1) + this.render() + // Volta ao topo da lista: sem isso, na 2ÂȘ pĂĄgina o motorista continua vendo + // o fim da anterior e parece que nada mudou. + this.element.scrollIntoView({ behavior: "smooth", block: "start" }) + } + + get totalPaginas() { + return Math.max(Math.ceil(this.itemTargets.length / this.tamanhoValue), 1) + } + + render() { + const inicio = this.pagina * this.tamanhoValue + const fim = inicio + this.tamanhoValue + + this.itemTargets.forEach((item, i) => { + item.classList.toggle("hidden", i < inicio || i >= fim) + }) + + if (!this.hasControlesTarget) return + + this.controlesTarget.classList.toggle("hidden", this.totalPaginas < 2) + if (this.hasIndicadorTarget) { + // "1–10 de 12" e nĂŁo "pĂĄgina 1 de 2": o motorista quer saber quantos + // fechamentos existem, nĂŁo quantas pĂĄginas a tela tem. + const total = this.itemTargets.length + this.indicadorTarget.textContent = `${inicio + 1}–${Math.min(fim, total)} de ${total}` + } + if (this.hasAnteriorTarget) this.alternar(this.anteriorTarget, this.pagina === 0) + if (this.hasProximoTarget) this.alternar(this.proximoTarget, this.pagina >= this.totalPaginas - 1) + } + + // BotĂŁo de ponta: desabilitado de verdade (nĂŁo sĂł apagado), para o toque nĂŁo + // "funcionar sem fazer nada". + alternar(botao, desligado) { + if (!botao) return + botao.disabled = desligado + botao.classList.toggle("opacity-30", desligado) + botao.classList.toggle("pointer-events-none", desligado) + } +} diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index bb0a9bb..96e057e 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -33,8 +33,18 @@ class Consolidacao < ApplicationRecord # ── Escopos ───────────────────────────────────────────────── scope :recentes, -> { order(created_at: :desc) } + # ⚠ `no_periodo` exige a consolidação INTEIRA dentro da faixa. NĂŁo Ă© o recorte + # das telas (elas usam `cruzando_periodo`, abaixo) — um fechamento de 01/08 a + # 31/08 sumia ao filtrar 01/08–26/08. SĂł use se o "caber dentro" for mesmo o + # que se quer. scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) } + # ConsolidaçÔes que CRUZAM o perĂ­odo (nĂŁo precisam caber inteiras dentro dele). + # É o recorte do dashboard financeiro (DashboardController#carregar_dados_ + # financeiro) e, por isso, o de qualquer tela que precise bater com ele: um + # fechamento de 01/08 a 31/08 tem que aparecer ao filtrar 01/08–26/08. + scope :cruzando_periodo, ->(i, f) { where('data_inicio <= ? AND data_fim >= ?', f, i) } + # ── Filtro por status de pagamento (derivado dos motoristas) ─ # pago_em NÃO-nulo conta como pago; COUNT(pago_em) ignora os NULL. scope :pagamento_pago, -> { diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb index dc1b1ec..fed4c8b 100644 --- a/app/services/analytics/totais_por_motorista.rb +++ b/app/services/analytics/totais_por_motorista.rb @@ -7,11 +7,18 @@ # lista, com drill-down no motorista clicado) # ‱ motorista→ /motorista (o painel, jĂĄ recortado nele — mesma conta, mesmo PDF) # -# RECORTE (igual ao filtro da lista de consolidaçÔes): entram as consolidaçÔes -# ATIVAS cujo perĂ­odo estĂĄ DENTRO da faixa escolhida (data_inicio >= inicio e -# data_fim <= fim). NĂŁo Ă© sobreposição de propĂłsito — uma consolidação que -# atravessa a borda do filtro entraria inteira e o "total do perĂ­odo" ficaria -# maior que o perĂ­odo. +# RECORTE — É O MESMO DO DASHBOARD FINANCEIRO, de propĂłsito +# (DashboardController#carregar_dados_financeiro). Os dois nĂșmeros sĂŁo lidos lado +# a lado pelo operador; quando divergem, a leitura Ă© "o sistema estĂĄ errado". +# Entram as consolidaçÔes ATIVAS que: +# ‱ CRUZAM o perĂ­odo (data_inicio <= fim AND data_fim >= inicio) — um +# fechamento de 01/08 a 31/08 aparece ao filtrar 01/08–26/08; OU +# ‱ tiveram algum PAGAMENTO dentro do perĂ­odo (pagar em agosto um fechamento +# de julho tem que aparecer em agosto). +# +# STATUS: por padrĂŁo sĂł as FINALIZADAS — Ă© o "Consolidado" do dashboard, o valor +# que jĂĄ fechou. 'todos' inclui os rascunhos em andamento (a visĂŁo "Geral"), que +# ainda mudam de valor. # # SĂł motoristas ATIVOS (nĂŁo arquivados) contam: Ă© o mesmo conjunto que a # consolidação paga (ConsolidacaoMotorista.ativos), entĂŁo o total desta tela @@ -36,15 +43,12 @@ module Analytics # telas mostram nome/perĂ­odo/status dela em seguida). def registros @registros ||= begin - base = Consolidacao.ativas.no_periodo(@inicio, @fim) - # Por padrĂŁo sĂł o que jĂĄ foi FECHADO conta como total do perĂ­odo — - # rascunho ainda muda de valor. `merge` (e nĂŁo where(consolidacoes: - # {status: ...})) porque status Ă© enum: quem traduz :finalizada Ă© o model. + base = Consolidacao.ativas + # `public_send` sobre o enum: quem traduz :finalizada/:rascunho Ă© o model. base = base.public_send(@status) if %w[finalizada rascunho].include?(@status) escopo = ConsolidacaoMotorista.ativos - .joins(:consolidacao) - .merge(base) + .where(consolidacao_id: ids_do_periodo(base)) .includes(:consolidacao) escopo = escopo.where(motorista_nome: @motorista) if @motorista escopo = escopo.where('motorista_nome ILIKE ?', "%#{@busca}%") if @busca @@ -94,6 +98,19 @@ module Analytics private + # As consolidaçÔes do perĂ­odo: as que cruzam a faixa + as que receberam + # pagamento dentro dela. Mesmo par de conjuntos do dashboard financeiro. + def ids_do_periodo(base) + intervalo = @inicio.beginning_of_day..@fim.end_of_day + + cruzam = base.cruzando_periodo(@inicio, @fim).ids + pagas = ConsolidacaoMotorista.ativos + .where(pago_em: intervalo) + .where(consolidacao_id: base.select(:id)) + .distinct.pluck(:consolidacao_id) + (cruzam + pagas).uniq + end + def soma(cms) cms.sum { |cm| cm.valor_total || 0 } end diff --git a/app/views/consolidacoes/_por_motorista.html.erb b/app/views/consolidacoes/_por_motorista.html.erb index 27408cd..bba2c91 100644 --- a/app/views/consolidacoes/_por_motorista.html.erb +++ b/app/views/consolidacoes/_por_motorista.html.erb @@ -3,14 +3,16 @@ ali mesmo (sem sair da tela), as consolidaçÔes que compĂ”em o valor dele. %> <%# Link desta mesma tela preservando filtros — usado pelas linhas e pelos PDFs. %> -<% ctx = { nome: params[:nome], motorista: params[:motorista], status: params[:status], +<% ctx = { nome: params[:nome], motorista: params[:motorista], status: @status_totais, inicio: @inicio.strftime('%Y-%m-%d'), fim: @fim.strftime('%Y-%m-%d'), visao: 'motoristas' }.compact_blank %> <%# ── Resumo do perĂ­odo ────────────────────────────────── %>
-

<%= icone :dinheiro %> Total do perĂ­odo

+

+ <%= icone :dinheiro %> <%= @status_totais == 'finalizada' ? 'Consolidado no perĂ­odo' : 'Total do perĂ­odo' %> +

<%= moeda(@totais.total_geral) %>

<%= @totais.total_consolidacoes %> consolidação(Ôes) · <%= @totais.linhas.size %> motorista(s)

@@ -120,9 +122,18 @@

- <%= icone :dica %> Entram as consolidaçÔes - <%= params[:status].presence ? "com status #{params[:status]}" : 'de qualquer status' %> - cujo perĂ­odo estĂĄ dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %>. - Motoristas arquivados na consolidação nĂŁo entram no total. + <%= icone :dica %> + <% if @status_totais == 'finalizada' %> + Consolidado = sĂł consolidaçÔes finalizadas — + Ă© o mesmo nĂșmero do card "Consolidado / Pago" do Dashboard no mesmo perĂ­odo. + Troque para Geral para somar tambĂ©m os rascunhos em andamento. + <% elsif @status_totais == 'rascunho' %> + Mostrando sĂł rascunhos — valores ainda em aberto, que mudam atĂ© o fechamento. + <% else %> + Geral = finalizadas + rascunhos, por isso fica acima do + "Consolidado / Pago" do Dashboard. Troque para Consolidado para comparar com ele. + <% end %> + Entram as consolidaçÔes que cruzam <%= l @inicio, format: :short %> → <%= l @fim, format: :short %> e as pagas dentro do perĂ­odo; + motoristas arquivados nĂŁo entram no total.

<% end %> diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 0dfb198..6bac899 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -111,9 +111,20 @@ 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' %> <% end %> - <%= f.select :status, - options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]), - {}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% if @visao == 'motoristas' %> + <%# "Consolidado" x "Geral" — a pergunta que o nĂșmero responde muda com isso: + Consolidado Ă© o valor JÁ FECHADO (o mesmo do card "Consolidado / Pago" do + Dashboard); Geral soma tambĂ©m os rascunhos, que ainda mudam de valor. %> + <%= f.select :status, + options_for_select([['Consolidado (fechadas)', 'finalizada'], + ['Geral (com rascunhos)', 'todos'], + ['SĂł rascunhos', 'rascunho']], @status_totais), + {}, class: 'flex-1 basis-52 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% else %> + <%= f.select :status, + options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]), + {}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% end %> <%# "Pagamento" Ă© status DA CONSOLIDAÇÃO (todos os motoristas dela); no modo Por motorista cada linha jĂĄ mostra pago/a receber, entĂŁo o filtro sairia diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index 5aae57c..1083ead 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -103,35 +103,57 @@ <% end %>
- <%# ── Card secundário: estimativa (ainda não fechada) ──── %> -
-
-

<%= icone :dinheiro, cor: nil, espaco: false %> Estimativa das entregas

- <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> + <%# ── Card secundĂĄrio: entregas do perĂ­odo ────────────── + REGRA: valor estimado e valor fechado NUNCA aparecem juntos. + Quando a empresa jĂĄ fechou o perĂ­odo, o nĂșmero certo Ă© o de cima; mostrar + ao lado uma estimativa (entregas × preço, sem descontos/bĂŽnus e sem as + regras de classificação) fazia o motorista ver dois valores diferentes para + o mesmo mĂȘs — e o estimado costuma ser MAIOR, o que vira cobrança. + Com fechamento: sĂł a CONTAGEM de entregas (o trabalho, sem prometer valor). + Sem fechamento: a prĂ©via em R$, que Ă© a Ășnica informação que ele tem. %> + <% if @consolidacoes.any? %> +
+
+

<%= icone :caminhao, espaco: false %> Suas entregas no perĂ­odo

+ <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> +
+

<%= @entregas_mes %>

+ <%# "Atendidas" = concluídas + sem sucesso: a empresa paga as duas, porque + em ambas o motorista foi até o local. %> +

+ <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) +

+

+ <%= icone :sucesso, cor: 'text-green-500' %> O valor deste perĂ­odo jĂĄ foi fechado pela empresa — Ă© o do card branco acima. +

-

<%= moeda(@valor_estimado) %>

- <%# "Atendidas" = concluídas + sem sucesso. O rótulo NÃO pode voltar a dizer - "feitas e confirmadas": o valor acima inclui as visitas sem sucesso, que - a empresa também paga (o motorista foi ao local). Sem a segunda linha o - motorista não consegue conferir a conta. %> -

<%= @entregas_mes %> entregas atendidas

-

- <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) -

-

- <%= icone :alerta, cor: nil %> Valor aproximado. O valor certo Ă© o de cima, quando a empresa fecha o pagamento. -

-
+ <% else %> +
+
+

<%= icone :dinheiro, cor: nil, espaco: false %> Prévia (ainda não fechado)

+ <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> +
+

<%= moeda(@valor_estimado) %>

+

<%= @entregas_mes %> entregas atendidas

+

+ <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) +

+

+ <%= icone :alerta, cor: nil %> Conta simples (entregas × valor da entrega). O valor certo sai quando a empresa fechar o período — bînus, retiradas e descontos entram só no fechamento. +

+
+ <% end %> <%# ── Composição: de onde vem o valor fechado ──────────── %>

<%= icone :composicao, espaco: false %> De onde vem esse valor

Cada fechamento abaixo entra na soma do card branco.

<% if @consolidacoes.any? %> +
<% @consolidacoes.each do |cm| %> <% c = cm.consolidacao %> -
+
@@ -162,6 +184,23 @@
<% end %>
+ + <%# Setas: alvos grandes (56px) porque a tela Ă© usada de luva/no trĂąnsito. + Somem sozinhas quando hĂĄ uma pĂĄgina sĂł (ver carrossel_controller.js). %> + +
<% else %>

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

diff --git a/spec/requests/totais_por_motorista_spec.rb b/spec/requests/totais_por_motorista_spec.rb index ed5d5be..529d420 100644 --- a/spec/requests/totais_por_motorista_spec.rb +++ b/spec/requests/totais_por_motorista_spec.rb @@ -32,6 +32,24 @@ RSpec.describe 'VisĂŁo por motorista', type: :request do expect(response.body).to include('R$ 120,00') end + # O default tem que ser o MESMO recorte do dashboard ("Consolidado / Pago"), + # senĂŁo o operador compara as duas telas e vĂȘ valores diferentes. + it 'sem status na URL mostra o CONSOLIDADO (rascunho fica de fora)' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + rascunho = create(:consolidacao, status: :rascunho, + data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30)) + create(:consolidacao_entrega, consolidacao: rascunho, motorista_nome: 'Carlos', valor_aplicado: 500.0) + create(:consolidacao_motorista, consolidacao: rascunho, motorista_nome: 'Carlos') + sign_in create(:gerente) + + get consolidacoes_path(periodo.merge(visao: 'motoristas')) + expect(response.body).to include('R$ 120,00') + expect(response.body).not_to include('R$ 620,00') + + get consolidacoes_path(periodo.merge(visao: 'motoristas', status: 'todos')) + expect(response.body).to include('R$ 620,00') + end + it 'sĂł abre a composição do motorista clicado' do consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') consolidacao_finalizada(motorista: 'Pedro', valor: 90.0, nome: 'Fechamento do Pedro') diff --git a/spec/services/analytics/totais_por_motorista_spec.rb b/spec/services/analytics/totais_por_motorista_spec.rb index 155a2b0..724c9e6 100644 --- a/spec/services/analytics/totais_por_motorista_spec.rb +++ b/spec/services/analytics/totais_por_motorista_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Analytics::TotaisPorMotorista do consolidar(motorista: 'Carlos', valor: 50.0, qtd: 1) # 50 consolidar(motorista: 'Pedro', valor: 30.0, qtd: 1) # 30 consolidar(motorista: 'Carlos', valor: 999.0, status: :rascunho) # fora (rascunho) - consolidar(motorista: 'Carlos', valor: 999.0, # fora (perĂ­odo) + consolidar(motorista: 'Carlos', valor: 999.0, # fora (nĂŁo cruza) data_inicio: Date.new(2026, 7, 1), data_fim: Date.new(2026, 7, 31)) expect(totais.linhas.map { |l| [l[:motorista], l[:valor_total]] }) @@ -37,6 +37,29 @@ RSpec.describe Analytics::TotaisPorMotorista do expect(totais.total_consolidacoes).to eq(3) end + # ── Recorte de datas: o MESMO do dashboard financeiro ─────────────────── + # Quando estes dois casos divergiam do dashboard, o operador via dois valores + # diferentes para o mesmo motorista no mesmo perĂ­odo e lia como erro de conta. + it 'inclui a consolidação que CRUZA o perĂ­odo (nĂŁo precisa caber inteira)' do + # 20/05 a 20/06: começa antes do filtro, termina dentro. + consolidar(motorista: 'Carlos', valor: 100.0, + data_inicio: Date.new(2026, 5, 20), data_fim: Date.new(2026, 6, 20)) + # 20/06 a 20/07: começa dentro, termina depois. + consolidar(motorista: 'Carlos', valor: 40.0, + data_inicio: Date.new(2026, 6, 20), data_fim: Date.new(2026, 7, 20)) + + expect(totais.total_geral).to eq(140.0) + end + + it 'inclui a consolidação de outro perĂ­odo PAGA dentro do perĂ­odo' do + _, cm = consolidar(motorista: 'Carlos', valor: 80.0, + data_inicio: Date.new(2026, 4, 1), data_fim: Date.new(2026, 4, 30)) + cm.update!(pago_em: Time.zone.local(2026, 6, 10, 12, 0), forma_pagamento: 'pix') + + expect(totais.total_geral).to eq(80.0) + expect(totais.total_pago).to eq(80.0) + end + it 'respeita o filtro de status (o mesmo select da lista)' do consolidar(motorista: 'Carlos', valor: 100.0) consolidar(motorista: 'Carlos', valor: 40.0, status: :rascunho) -- 2.49.1 From e4b17653ef326ec55a9d01baf3b7a91797c9a845d26ce3a9f3976537bbf06cb6 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 15:40:50 -0300 Subject: [PATCH 15/40] =?UTF-8?q?Modifica=C3=A7=C3=A3o=20na=20area=20do=20?= =?UTF-8?q?motorista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/carrossel_controller.js | 82 +++++++++++++++---- app/views/consolidacoes/_lista.html.erb | 8 ++ .../consolidacoes/_por_motorista.html.erb | 11 ++- app/views/motorista/dashboard/index.html.erb | 17 +--- .../shared/_carrossel_controles.html.erb | 24 ++++++ 5 files changed, 108 insertions(+), 34 deletions(-) create mode 100644 app/views/shared/_carrossel_controles.html.erb diff --git a/app/javascript/controllers/carrossel_controller.js b/app/javascript/controllers/carrossel_controller.js index 74e6dbd..2793498 100644 --- a/app/javascript/controllers/carrossel_controller.js +++ b/app/javascript/controllers/carrossel_controller.js @@ -1,42 +1,90 @@ // app/javascript/controllers/carrossel_controller.js -// Stimulus — paginação em "carrossel" para listas longas no celular (painel do -// motorista). Os itens jĂĄ vĂȘm todos no HTML; aqui sĂł mostramos uma PÁGINA por -// vez, com setas e o indicador "1 / 3". +// Stimulus — paginação em "carrossel" para listas longas: mostra N itens por vez +// com setas grandes e o indicador "1–10 de 12". // -// Por que no cliente e nĂŁo com ?pg= no servidor: a lista divide a tela com os -// cards de valor e o filtro de perĂ­odo — recarregar a pĂĄgina a cada seta faria o -// motorista perder a posição e piscar a tela inteira num 3G. +// PADRÃO EM TELA PEQUENA, LISTA INTEIRA EM TELA GRANDE. Em celular e monitor +// pequeno, uma lista de 30 cards vira rolagem infinita e o rodapĂ©/açÔes da tela +// somem de vista; num monitor grande a mesma lista cabe e paginar sĂł atrapalha +// (Ctrl+F do navegador nĂŁo acha o que estĂĄ escondido). O corte Ă© por largura de +// janela — `maxLargura` — e Ă© reavaliado no resize/rotação da tela. // -// Sem itens suficientes para duas pĂĄginas, os controles somem sozinhos (nada de -// seta morta ocupando espaço). +// Os itens jĂĄ vĂȘm todos no HTML; aqui sĂł escondemos os de fora da pĂĄgina. Por +// isso nĂŁo serve para listas de centenas de itens (aĂ­ Ă© paginação no servidor). +// +// Uso: +//
<- acima disso, mostra tudo +//


<- cada item +// 
 +// +//
+// +// Um item com data-carrossel-inicial="true" (ex.: o motorista com a composição +// aberta) define em qual pĂĄgina o carrossel abre — senĂŁo o clique dele "sumia". import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["item", "controles", "indicador", "anterior", "proximo"] - static values = { tamanho: { type: Number, default: 10 } } + static values = { + tamanho: { type: Number, default: 10 }, + maxLargura: { type: Number, default: 1366 } + } connect() { - this.pagina = 0 + this.pagina = this.paginaInicial() + this._aoRedimensionar = () => this.render() + window.addEventListener("resize", this._aoRedimensionar) this.render() } - proxima() { this.ir(this.pagina + 1) } + disconnect() { + window.removeEventListener("resize", this._aoRedimensionar) + } + + proxima() { this.ir(this.pagina + 1) } anterior() { this.ir(this.pagina - 1) } ir(destino) { this.pagina = Math.min(Math.max(destino, 0), this.totalPaginas - 1) this.render() - // Volta ao topo da lista: sem isso, na 2ÂȘ pĂĄgina o motorista continua vendo - // o fim da anterior e parece que nada mudou. + // Volta ao topo da lista: sem isso, na 2ÂȘ pĂĄgina o leitor continua vendo o + // fim da anterior e parece que nada mudou. this.element.scrollIntoView({ behavior: "smooth", block: "start" }) } + // Tela grande: sem paginação (a lista inteira aparece). + get paginando() { + return window.innerWidth <= this.maxLarguraValue && + this.itemTargets.length > this.tamanhoValue + } + get totalPaginas() { return Math.max(Math.ceil(this.itemTargets.length / this.tamanhoValue), 1) } + paginaInicial() { + const i = this.itemTargets.findIndex((el) => el.dataset.carrosselInicial === "true") + return i < 0 ? 0 : Math.floor(i / this.tamanhoValue) + } + render() { + const total = this.itemTargets.length + + if (!this.paginando) { + this.itemTargets.forEach((item) => item.classList.remove("hidden")) + if (this.hasControlesTarget) this.controlesTarget.classList.add("hidden") + return + } + + // Uma tela menor pode ter reduzido o total de pĂĄginas desde a Ășltima vez. + this.pagina = Math.min(this.pagina, this.totalPaginas - 1) + const inicio = this.pagina * this.tamanhoValue const fim = inicio + this.tamanhoValue @@ -45,12 +93,11 @@ export default class extends Controller { }) if (!this.hasControlesTarget) return + this.controlesTarget.classList.remove("hidden") - this.controlesTarget.classList.toggle("hidden", this.totalPaginas < 2) if (this.hasIndicadorTarget) { - // "1–10 de 12" e nĂŁo "pĂĄgina 1 de 2": o motorista quer saber quantos - // fechamentos existem, nĂŁo quantas pĂĄginas a tela tem. - const total = this.itemTargets.length + // "1–10 de 12" e nĂŁo "pĂĄgina 1 de 2": quem lĂȘ quer saber quantos itens + // existem, nĂŁo quantas pĂĄginas a tela tem. this.indicadorTarget.textContent = `${inicio + 1}–${Math.min(fim, total)} de ${total}` } if (this.hasAnteriorTarget) this.alternar(this.anteriorTarget, this.pagina === 0) @@ -60,7 +107,6 @@ export default class extends Controller { // BotĂŁo de ponta: desabilitado de verdade (nĂŁo sĂł apagado), para o toque nĂŁo // "funcionar sem fazer nada". alternar(botao, desligado) { - if (!botao) return botao.disabled = desligado botao.classList.toggle("opacity-30", desligado) botao.classList.toggle("pointer-events-none", desligado) diff --git a/app/views/consolidacoes/_lista.html.erb b/app/views/consolidacoes/_lista.html.erb index a4c4307..e359537 100644 --- a/app/views/consolidacoes/_lista.html.erb +++ b/app/views/consolidacoes/_lista.html.erb @@ -1,8 +1,12 @@ <%# app/views/consolidacoes/_lista.html.erb — visĂŁo por CONSOLIDAÇÃO %> <% if @consolidacoes.any? %> + <%# Em celular/monitor pequeno a lista vira carrossel de 10 em 10; em tela + grande aparece inteira (ver carrossel_controller.js). %> +
<% @consolidacoes.each do |c| %> <%= link_to consolidacao_path(c), + data: { carrossel_target: 'item' }, class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %>
@@ -28,6 +32,10 @@ <% end %> <% end %>
+ + <%= render 'shared/carrossel_controles', + anterior: 'ConsolidaçÔes anteriores', proximo: 'Próximas consolidaçÔes' %> +
<% else %>

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

diff --git a/app/views/consolidacoes/_por_motorista.html.erb b/app/views/consolidacoes/_por_motorista.html.erb index bba2c91..06eaf02 100644 --- a/app/views/consolidacoes/_por_motorista.html.erb +++ b/app/views/consolidacoes/_por_motorista.html.erb @@ -46,10 +46,15 @@

Ajuste as datas acima — o recorte pega a consolidação inteira dentro da faixa.

<% else %> + <%# Em celular/monitor pequeno vira carrossel de 10 em 10; em tela grande sai a + lista inteira. O motorista com a composição aberta define a página inicial + (data-carrossel-inicial) — senão o clique dele cairia numa página escondida. %> +
<% @totais.linhas.each do |linha| %> <% aberto = @detalhe_motorista == linha[:motorista] %> -
+
<%# Cabeçalho do motorista — o clique abre/fecha a composição %> <%= link_to consolidacoes_path(ctx.merge(detalhe: (aberto ? nil : linha[:motorista])).compact_blank), @@ -121,6 +126,10 @@ <% end %>
+ <%= render 'shared/carrossel_controles', + anterior: 'Motoristas anteriores', proximo: 'PrĂłximos motoristas' %> +
+

<%= icone :dica %> <% if @status_totais == 'finalizada' %> diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index 1083ead..ac30001 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -185,21 +185,8 @@ <% end %>

- <%# Setas: alvos grandes (56px) porque a tela Ă© usada de luva/no trĂąnsito. - Somem sozinhas quando hĂĄ uma pĂĄgina sĂł (ver carrossel_controller.js). %> - + <%= render 'shared/carrossel_controles', + anterior: 'Fechamentos anteriores', proximo: 'PrĂłximos fechamentos' %>
<% else %>
diff --git a/app/views/shared/_carrossel_controles.html.erb b/app/views/shared/_carrossel_controles.html.erb new file mode 100644 index 0000000..8ab3b90 --- /dev/null +++ b/app/views/shared/_carrossel_controles.html.erb @@ -0,0 +1,24 @@ +<%# app/views/shared/_carrossel_controles.html.erb + Setas + indicador do carrossel (ver carrossel_controller.js). Nascem + escondidos: quem decide se esta tela Ă© pequena o bastante para paginar Ă© o + JS — em monitor grande a lista aparece inteira e estes controles nĂŁo voltam. + + Alvos de 56px: a tela Ă© usada no celular, em movimento. + + locals: anterior:/proximo: (rĂłtulos acessĂ­veis, opcionais). %> +<% anterior = local_assigns.fetch(:anterior, 'Itens anteriores') %> +<% proximo = local_assigns.fetch(:proximo, 'PrĂłximos itens') %> + + -- 2.49.1 From 044747a36b2b31bd239b005a01d8eb416635ff8e87436c8f65219df4c6e65e79 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 15:45:25 -0300 Subject: [PATCH 16/40] =?UTF-8?q?Modifica=C3=A7=C3=A3o=20na=20area=20do=20?= =?UTF-8?q?motorista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/carrossel_controller.js | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/app/javascript/controllers/carrossel_controller.js b/app/javascript/controllers/carrossel_controller.js index 2793498..a306cd7 100644 --- a/app/javascript/controllers/carrossel_controller.js +++ b/app/javascript/controllers/carrossel_controller.js @@ -1,27 +1,30 @@ // app/javascript/controllers/carrossel_controller.js -// Stimulus — paginação em "carrossel" para listas longas: mostra N itens por vez -// com setas grandes e o indicador "1–10 de 12". +// Stimulus — paginação em "carrossel" para listas longas: mostra uma pĂĄgina de +// itens por vez, com setas grandes e o indicador "1–5 de 12". // -// PADRÃO EM TELA PEQUENA, LISTA INTEIRA EM TELA GRANDE. Em celular e monitor -// pequeno, uma lista de 30 cards vira rolagem infinita e o rodapĂ©/açÔes da tela -// somem de vista; num monitor grande a mesma lista cabe e paginar sĂł atrapalha -// (Ctrl+F do navegador nĂŁo acha o que estĂĄ escondido). O corte Ă© por largura de -// janela — `maxLargura` — e Ă© reavaliado no resize/rotação da tela. +// TAMANHO DE PÁGINA POR TELA (Ă© o ponto do controller): +// ‱ celular (atĂ© 640px) → 5 itens — cada card ocupa muita altura; +// 10 jĂĄ viravam rolagem infinita; +// ‱ atĂ© 1366px → 10 itens — tablet e notebook pequeno; +// ‱ acima disso → lista INTEIRA, sem setas. Em monitor grande a +// lista cabe e esconder item sĂł atrapalha (o +// Ctrl+F do navegador nĂŁo acha o que estĂĄ oculto). +// +// O corte Ă© reavaliado no resize/rotação: girar o celular ou encostar a janela +// na lateral jĂĄ muda o comportamento, e o item que estava no topo continua na +// tela (a posição Ă© guardada pelo ÍNDICE do item, nĂŁo pelo nĂșmero da pĂĄgina). // // Os itens jĂĄ vĂȘm todos no HTML; aqui sĂł escondemos os de fora da pĂĄgina. Por // isso nĂŁo serve para listas de centenas de itens (aĂ­ Ă© paginação no servidor). // // Uso: //
<- acima disso, mostra tudo -//


<- cada item -// 
 -// +// data-carrossel-tamanho-value="10" <- itens por pågina +// data-carrossel-tamanho-compacto-value="5" <- itens no celular +// data-carrossel-largura-compacta-value="640" <- até aqui é "celular" +// data-carrossel-max-largura-value="1366"> <- acima disso, mostra tudo +//


+// <%= render 'shared/carrossel_controles' %> //
// // Um item com data-carrossel-inicial="true" (ex.: o motorista com a composição @@ -32,12 +35,17 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["item", "controles", "indicador", "anterior", "proximo"] static values = { - tamanho: { type: Number, default: 10 }, - maxLargura: { type: Number, default: 1366 } + tamanho: { type: Number, default: 10 }, + tamanhoCompacto: { type: Number, default: 5 }, + larguraCompacta: { type: Number, default: 640 }, + maxLargura: { type: Number, default: 1366 } } connect() { - this.pagina = this.paginaInicial() + // Âncora = Ă­ndice do primeiro item da pĂĄgina atual. Guardar o ÍNDICE (e nĂŁo + // o nĂșmero da pĂĄgina) Ă© o que mantĂ©m o mesmo item na tela quando o tamanho + // da pĂĄgina muda junto com a largura. + this.ancora = this.indiceInicial() this._aoRedimensionar = () => this.render() window.addEventListener("resize", this._aoRedimensionar) this.render() @@ -51,26 +59,37 @@ export default class extends Controller { anterior() { this.ir(this.pagina - 1) } ir(destino) { - this.pagina = Math.min(Math.max(destino, 0), this.totalPaginas - 1) + const pagina = Math.min(Math.max(destino, 0), this.totalPaginas - 1) + this.ancora = pagina * this.tamanho this.render() - // Volta ao topo da lista: sem isso, na 2ÂȘ pĂĄgina o leitor continua vendo o - // fim da anterior e parece que nada mudou. + // Volta ao topo da lista: sem isso, na pĂĄgina seguinte o leitor continua + // vendo o fim da anterior e parece que nada mudou. this.element.scrollIntoView({ behavior: "smooth", block: "start" }) } + get tamanho() { + return window.innerWidth <= this.larguraCompactaValue + ? this.tamanhoCompactoValue + : this.tamanhoValue + } + // Tela grande: sem paginação (a lista inteira aparece). get paginando() { return window.innerWidth <= this.maxLarguraValue && - this.itemTargets.length > this.tamanhoValue + this.itemTargets.length > this.tamanho } get totalPaginas() { - return Math.max(Math.ceil(this.itemTargets.length / this.tamanhoValue), 1) + return Math.max(Math.ceil(this.itemTargets.length / this.tamanho), 1) } - paginaInicial() { + get pagina() { + return Math.min(Math.floor(this.ancora / this.tamanho), this.totalPaginas - 1) + } + + indiceInicial() { const i = this.itemTargets.findIndex((el) => el.dataset.carrosselInicial === "true") - return i < 0 ? 0 : Math.floor(i / this.tamanhoValue) + return i < 0 ? 0 : i } render() { @@ -82,11 +101,8 @@ export default class extends Controller { return } - // Uma tela menor pode ter reduzido o total de pĂĄginas desde a Ășltima vez. - this.pagina = Math.min(this.pagina, this.totalPaginas - 1) - - const inicio = this.pagina * this.tamanhoValue - const fim = inicio + this.tamanhoValue + const inicio = this.pagina * this.tamanho + const fim = inicio + this.tamanho this.itemTargets.forEach((item, i) => { item.classList.toggle("hidden", i < inicio || i >= fim) @@ -96,7 +112,7 @@ export default class extends Controller { this.controlesTarget.classList.remove("hidden") if (this.hasIndicadorTarget) { - // "1–10 de 12" e nĂŁo "pĂĄgina 1 de 2": quem lĂȘ quer saber quantos itens + // "1–5 de 12" e nĂŁo "pĂĄgina 1 de 3": quem lĂȘ quer saber quantos itens // existem, nĂŁo quantas pĂĄginas a tela tem. this.indicadorTarget.textContent = `${inicio + 1}–${Math.min(fim, total)} de ${total}` } -- 2.49.1 From 808c2c7545f2a0fcc733dcff2d6a2c54c7ebf833039b8029ac5ba2d87adb1a05 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 15:51:55 -0300 Subject: [PATCH 17/40] =?UTF-8?q?Modifica=C3=A7=C3=A3o=20na=20area=20do=20?= =?UTF-8?q?motorista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/carrossel_controller.js | 21 ++++--- app/views/consolidacoes/_lista.html.erb | 2 +- .../consolidacoes/_por_motorista.html.erb | 2 +- app/views/motorista/dashboard/index.html.erb | 2 +- .../shared/_carrossel_controles.html.erb | 55 ++++++++++++++++--- 5 files changed, 63 insertions(+), 19 deletions(-) diff --git a/app/javascript/controllers/carrossel_controller.js b/app/javascript/controllers/carrossel_controller.js index a306cd7..3a53ca0 100644 --- a/app/javascript/controllers/carrossel_controller.js +++ b/app/javascript/controllers/carrossel_controller.js @@ -97,7 +97,7 @@ export default class extends Controller { if (!this.paginando) { this.itemTargets.forEach((item) => item.classList.remove("hidden")) - if (this.hasControlesTarget) this.controlesTarget.classList.add("hidden") + this.controlesTargets.forEach((el) => el.classList.add("hidden")) return } @@ -109,15 +109,18 @@ export default class extends Controller { }) if (!this.hasControlesTarget) return - this.controlesTarget.classList.remove("hidden") + this.controlesTargets.forEach((el) => el.classList.remove("hidden")) - if (this.hasIndicadorTarget) { - // "1–5 de 12" e nĂŁo "pĂĄgina 1 de 3": quem lĂȘ quer saber quantos itens - // existem, nĂŁo quantas pĂĄginas a tela tem. - this.indicadorTarget.textContent = `${inicio + 1}–${Math.min(fim, total)} de ${total}` - } - if (this.hasAnteriorTarget) this.alternar(this.anteriorTarget, this.pagina === 0) - if (this.hasProximoTarget) this.alternar(this.proximoTarget, this.pagina >= this.totalPaginas - 1) + // "1–5 de 12" e nĂŁo "pĂĄgina 1 de 3": quem lĂȘ quer saber quantos itens + // existem, nĂŁo quantas pĂĄginas a tela tem. + const rotulo = `${inicio + 1}–${Math.min(fim, total)} de ${total}` + this.indicadorTargets.forEach((el) => { el.textContent = rotulo }) + + // Plural: o mesmo controle aparece em dois layouts (setas laterais em tela + // com espaço, barra flutuante no rodapĂ© no celular) e os dois precisam + // desabilitar juntos. + this.anteriorTargets.forEach((b) => this.alternar(b, this.pagina === 0)) + this.proximoTargets.forEach((b) => this.alternar(b, this.pagina >= this.totalPaginas - 1)) } // BotĂŁo de ponta: desabilitado de verdade (nĂŁo sĂł apagado), para o toque nĂŁo diff --git a/app/views/consolidacoes/_lista.html.erb b/app/views/consolidacoes/_lista.html.erb index e359537..5186478 100644 --- a/app/views/consolidacoes/_lista.html.erb +++ b/app/views/consolidacoes/_lista.html.erb @@ -2,7 +2,7 @@ <% if @consolidacoes.any? %> <%# Em celular/monitor pequeno a lista vira carrossel de 10 em 10; em tela grande aparece inteira (ver carrossel_controller.js). %> -
+
<% @consolidacoes.each do |c| %> <%= link_to consolidacao_path(c), diff --git a/app/views/consolidacoes/_por_motorista.html.erb b/app/views/consolidacoes/_por_motorista.html.erb index 06eaf02..d1ddd2a 100644 --- a/app/views/consolidacoes/_por_motorista.html.erb +++ b/app/views/consolidacoes/_por_motorista.html.erb @@ -49,7 +49,7 @@ <%# Em celular/monitor pequeno vira carrossel de 10 em 10; em tela grande sai a lista inteira. O motorista com a composição aberta define a página inicial (data-carrossel-inicial) — senão o clique dele cairia numa página escondida. %> -
+
<% @totais.linhas.each do |linha| %> <% aberto = @detalhe_motorista == linha[:motorista] %> diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index ac30001..15069dd 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -149,7 +149,7 @@

Cada fechamento abaixo entra na soma do card branco.

<% if @consolidacoes.any? %> -
+
<% @consolidacoes.each do |cm| %> <% c = cm.consolidacao %> diff --git a/app/views/shared/_carrossel_controles.html.erb b/app/views/shared/_carrossel_controles.html.erb index 8ab3b90..d51da24 100644 --- a/app/views/shared/_carrossel_controles.html.erb +++ b/app/views/shared/_carrossel_controles.html.erb @@ -1,7 +1,17 @@ <%# app/views/shared/_carrossel_controles.html.erb - Setas + indicador do carrossel (ver carrossel_controller.js). Nascem - escondidos: quem decide se esta tela Ă© pequena o bastante para paginar Ă© o - JS — em monitor grande a lista aparece inteira e estes controles nĂŁo voltam. + Controles do carrossel (ver carrossel_controller.js) em DOIS layouts, porque + "seta flutuante na lateral" sĂł funciona onde sobra espaço lateral: + + ‱ tela com espaço (sm+): setas redondas flutuando FORA dos cards, + centralizadas na altura da lista — o container reserva a faixa com + `sm:px-16`, senĂŁo a seta passa por cima do texto do card; + ‱ celular: barra flutuante colada no rodapĂ© (sticky), com as duas setas e + o contador no meio. Na largura de um celular nĂŁo hĂĄ faixa lateral livre: + a seta lateral cobriria o conteĂșdo, e a barra inferior ainda fica no + alcance do polegar. + + ⚠ O container do carrossel precisa de `relative sm:px-16` — ver os usos em + consolidacoes/_lista, _por_motorista e motorista/dashboard/index. Alvos de 56px: a tela Ă© usada no celular, em movimento. @@ -9,16 +19,47 @@ <% anterior = local_assigns.fetch(:anterior, 'Itens anteriores') %> <% proximo = local_assigns.fetch(:proximo, 'PrĂłximos itens') %> - <%# ── VariĂĄveis ─────────────────────────────────────── %> + <%# ── EMOJIS ───────────────────────────────────────── + Digitar emoji no teclado do computador Ă© o passo que trava o ADM; + aqui ele clica. A lista Ă© curta e curada de propĂłsito — um seletor + completo viraria uma biblioteca externa (e CSP) para inserir 3 + caracteres. %> +
+

Emojis

+
+ <% %w[✅ ❌ ⚠ 📌 📱 💰 đŸ’” đŸ§Ÿ 📅 🕒 🚚 📩 📍 đŸ‘€ 👍 🙏 ⭐ 🔮 🟱 🟡 âžĄïž 📄 đŸ“· 🔔 ✍ 😀 🎉].each do |emoji| %> + + <% end %> +
+
+

VariĂĄveis

Clique num campo da mensagem e depois numa variĂĄvel para inseri-la ali.

-
- <% @variaveis.each do |nome, meta| %> - - <% end %> -
+ + <%# Agrupadas por ORIGEM e sem esconder o que não se aplica: a variável + de outro gatilho aparece apagada, com o aviso de que sai em branco. + Antes, a tela só listava as 6 do contexto — qualquer outro dado + exigia deploy. %> + <% @variaveis.each do |origem, itens| %> + <% next if itens.empty? %> +

+ <%= Notificacao::CatalogoVariaveis::ORIGENS[origem] %> +

+
+ <% itens.each do |v| %> + + <% end %> +
+ <% end %> + +

+ As apagadas (·) não existem neste gatilho e sairiam em branco. + <%= link_to 'Criar variåvel própria', admin_variaveis_path, class: 'text-orange-500 hover:underline' %>. +

@@ -312,20 +344,29 @@ }); } - // ── VariĂĄveis: insere no cursor do Ășltimo campo focado ───── + // ── Inserção no cursor do Ășltimo campo focado ────────────── + // Serve para variĂĄvel e para emoji: a mecĂąnica Ă© a mesma (recortar o valor + // na posição do cursor e reemitir `input` para o JSON do editor atualizar). + function inserirNoCampo(texto) { + if (!ultimoCampo) { alert('Clique primeiro no campo onde o texto deve entrar.'); return; } + var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length; + var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini; + ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim); + ultimoCampo.dispatchEvent(new Event('input', { bubbles: true })); + ultimoCampo.focus(); + ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length; + } + document.querySelectorAll('.chip-variavel').forEach(function (chip) { chip.addEventListener('click', function () { - var texto = '{{' + chip.dataset.variavel + '}}'; - if (!ultimoCampo) { alert('Clique primeiro no campo onde a variĂĄvel deve entrar.'); return; } - var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length; - var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini; - ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim); - ultimoCampo.dispatchEvent(new Event('input', { bubbles: true })); - ultimoCampo.focus(); - ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length; + inserirNoCampo('{{' + chip.dataset.variavel + '}}'); }); }); + document.querySelectorAll('.chip-emoji').forEach(function (chip) { + chip.addEventListener('click', function () { inserirNoCampo(chip.dataset.emoji); }); + }); + // ── Preview: renderizado no servidor, pelo mesmo cĂłdigo do envio ── var token = document.querySelector('meta[name="csrf-token"]'); document.getElementById('btn-preview').addEventListener('click', function () { diff --git a/app/views/admin/perfis_acesso/_form.html.erb b/app/views/admin/perfis_acesso/_form.html.erb new file mode 100644 index 0000000..0ce7ff5 --- /dev/null +++ b/app/views/admin/perfis_acesso/_form.html.erb @@ -0,0 +1,83 @@ +<%# app/views/admin/perfis_acesso/_form.html.erb + O ADM marca o que o perfil enxerga. As caixas vĂȘm agrupadas por ĂĄrea do + catĂĄlogo (Permissao.por_grupo) e cada uma traz a descrição do EFEITO real — + "Registrar pagamento" sem a linha de baixo nĂŁo diz se inclui estornar. %> +<%= form_with model: [:admin, perfil], url: (perfil.persisted? ? admin_perfil_path(perfil) : admin_perfis_path), + data: { turbo: false }, class: 'space-y-6' do |f| %> + + <% if perfil.errors.any? %> +
+

<%= icone :erro, cor: 'text-red-400' %> NĂŁo foi possĂ­vel salvar

+
    + <% perfil.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+
+ <% end %> + + <%# ── Identificação ──────────────────────────────────── %> +
+
+ <%= f.label :nome, 'Nome do perfil', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_field :nome, required: true, placeholder: 'Ex.: Gerente sem configuraçÔes', + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316]' %> +
+ +
+ <%= f.label :descricao, 'Para que serve', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_field :descricao, placeholder: 'Quem usa este perfil e por quĂȘ', + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316]' %> +
+ +
+
+ <%= f.label :ativo, 'Perfil ativo', class: 'block text-sm font-medium text-gray-300' %> +

Desativado, quem estiver nele perde os acessos marcados aqui.

+
+ <%= f.check_box :ativo, class: 'w-5 h-5 accent-orange-500' %> +
+ + <% if perfil.sistema? %> +

+ <%= icone :alerta, cor: 'text-amber-400' %> Perfil que vem com o sistema: pode ser editado e desativado, mas nĂŁo excluĂ­do. +

+ <% end %> +
+ + <%# ── PermissĂ”es ───────────────────────────────────────── + O campo vazio antes das caixas Ă© obrigatĂłrio: sem ele, desmarcar TUDO faria + o parĂąmetro sumir do POST e o controller entenderia "nĂŁo mexeu". %> + <%= hidden_field_tag 'perfil_acesso[permissoes][]', '' %> + + <% marcadas = perfil.permissoes_validas %> + <% Permissao.por_grupo.each do |grupo, itens| %> +
+
+

<%= Permissao.grupo_label(grupo) %>

+ <%= itens.count { |chave, _| marcadas.include?(chave) } %> de <%= itens.size %> +
+ +
+ <% itens.each do |chave, cfg| %> + + <% end %> +
+
+ <% end %> + +
+ <%= f.submit perfil.persisted? ? 'Salvar perfil' : 'Criar perfil', + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-6 py-3.5 rounded-xl + min-h-[48px] cursor-pointer' %> + <%= link_to 'Cancelar', admin_perfis_path, + class: 'px-5 py-3.5 text-gray-300 hover:text-white border border-[#2a2a2a] rounded-xl min-h-[48px] flex items-center' %> +
+<% end %> diff --git a/app/views/admin/perfis_acesso/edit.html.erb b/app/views/admin/perfis_acesso/edit.html.erb new file mode 100644 index 0000000..7452162 --- /dev/null +++ b/app/views/admin/perfis_acesso/edit.html.erb @@ -0,0 +1,10 @@ +<% content_for :title, "Perfil #{@perfil.nome}" %> +
+

+ <%= icone :usuarios, espaco: false %> <%= @perfil.nome %> +

+

+ <%= @perfil.users.count %> usuĂĄrio(s) neste perfil — o que vocĂȘ mudar aqui vale para todos eles. +

+ <%= render 'form', perfil: @perfil %> +
diff --git a/app/views/admin/perfis_acesso/index.html.erb b/app/views/admin/perfis_acesso/index.html.erb new file mode 100644 index 0000000..215ce04 --- /dev/null +++ b/app/views/admin/perfis_acesso/index.html.erb @@ -0,0 +1,70 @@ +<%# app/views/admin/perfis_acesso/index.html.erb %> +<% content_for :title, 'Perfis de acesso' %> + +
+
+

+ <%= icone :usuarios, espaco: false %> Perfis de acesso +

+

+ O perfil define o que a pessoa enxerga e pode fazer. Mudou a regra, muda aqui — vale para todos do perfil. +

+
+ <%= link_to new_admin_perfil_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil %> Novo perfil + <% end %> +
+ +<% if @perfis.any? %> +
+ <% @perfis.each do |perfil| %> +
+
+
+
+

<%= perfil.nome %>

+ <% if perfil.sistema? %> + Do sistema + <% end %> + <% unless perfil.ativo? %> + Desativado + <% end %> + <% if perfil.vazio? %> + Sem nenhum acesso + <% end %> +
+ <% if perfil.descricao.present? %> +

<%= perfil.descricao %>

+ <% end %> +

+ <%= icone :pessoa %> <%= perfil.users.size %> usuårio(s) + · <%= icone :chave %> <%= perfil.total_permissoes %> de <%= Permissao.chaves.size %> permissÔes +

+
+ +
+ <%= link_to rotulo(:editar, 'Editar', cor: nil), edit_admin_perfil_path(perfil), data: { turbo: false }, + class: 'px-4 py-3 text-gray-300 hover:text-white border border-[#2a2a2a] hover:border-orange-500 rounded-xl min-h-[48px] flex items-center' %> + <%# button_to com BLOCO: passando o rĂłtulo como primeiro argumento, o + Rails escapa o HTML e o Ă­cone sai como texto cru na tela. %> + <% if policy(perfil).destroy? %> + <%= button_to admin_perfil_path(perfil), method: :delete, + class: 'px-4 py-3 text-red-400 hover:text-red-300 border border-[#2a2a2a] hover:border-red-500 rounded-xl min-h-[48px] flex items-center gap-2', + form: { data: { turbo_confirm: "Excluir o perfil #{perfil.nome}?" } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Excluir + <% end %> + <% end %> +
+
+
+ <% end %> +
+<% else %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhum perfil cadastrado ainda.

+

Enquanto nĂŁo houver perfil, cada pessoa acessa conforme o tipo de conta dela.

+
+<% end %> diff --git a/app/views/admin/perfis_acesso/new.html.erb b/app/views/admin/perfis_acesso/new.html.erb new file mode 100644 index 0000000..fb64b19 --- /dev/null +++ b/app/views/admin/perfis_acesso/new.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, 'Novo perfil de acesso' %> +
+

+ <%= icone :usuarios, espaco: false %> Novo perfil de acesso +

+

Marque o que este perfil enxerga. VocĂȘ atribui o perfil a cada pessoa na tela de UsuĂĄrios.

+ <%= render 'form', perfil: @perfil %> +
diff --git a/app/views/admin/usuarios/_form.html.erb b/app/views/admin/usuarios/_form.html.erb index d0f8573..aeb2400 100644 --- a/app/views/admin/usuarios/_form.html.erb +++ b/app/views/admin/usuarios/_form.html.erb @@ -40,16 +40,46 @@ placeholder: 'JoĂŁo da Silva' %>
- <%# Perfil %> -
- <%= f.label :role, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> - <%= f.select :role, - options_for_select(role_options_for_select, usuario.role), - {}, - class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white - focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] - transition-colors cursor-pointer' %> -
+ <%# TIPO DE CONTA x PERFIL DE ACESSO — sĂŁo coisas diferentes: + ‱ tipo de conta define COMO a pessoa entra e onde ela vive (motorista + entra por PIN e tem painel prĂłprio); + ‱ perfil de acesso define O QUE ela enxerga (Permissao::TODAS). + Antes este select se chamava "Perfil de acesso" e editava `role`. + + Os dois sĂł aparecem para quem pode alterar acesso — e nunca na prĂłpria + ficha, senĂŁo qualquer usuĂĄrio se promoveria editando o prĂłprio cadastro + (o controller tambĂ©m filtra os campos, nĂŁo sĂł a tela). %> + <% if pode_alterar_acesso? %> +
+ <%= f.label :role, 'Tipo de conta', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.select :role, + options_for_select(role_options_for_select, usuario.role), + {}, + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] + transition-colors cursor-pointer' %> +
+ +
+ <%= f.label :perfil_acesso_id, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.select :perfil_acesso_id, + options_from_collection_for_select(PerfilAcesso.ativos.ordenados, :id, :nome, usuario.perfil_acesso_id), + { include_blank: 'Sem perfil (usa o padrĂŁo do tipo de conta)' }, + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] + transition-colors cursor-pointer' %> +

+ Define o que a pessoa enxerga. + <%= link_to 'Gerenciar perfis', admin_perfis_path, class: 'text-orange-500 hover:underline' %> +

+
+ <% else %> +
+

Tipo de conta

+

<%= usuario.role_label %>

+

SĂł quem administra usuĂĄrios altera tipo de conta e perfil de acesso.

+
+ <% end %> <%# Ativo toggle %>
@@ -142,10 +172,16 @@ const fieldsPin = document.getElementById('fields-pin'); if (!roleSelect || !fieldsEmail || !fieldsPin) return; + // Motorista nĂŁo usa perfil de acesso: o que ele enxerga Ă© o painel prĂłprio, + // governado pelo tipo de conta. Deixar o select Ă  mostra sugeriria que dĂĄ + // para liberar telas administrativas para ele. + const campoPerfil = document.getElementById('campo-perfil-acesso'); + function toggleFields() { const motorista = roleSelect.value === 'motorista'; fieldsEmail.classList.toggle('hidden', motorista); fieldsPin.classList.toggle('hidden', !motorista); + if (campoPerfil) campoPerfil.classList.toggle('hidden', motorista); } roleSelect.addEventListener('change', toggleFields); diff --git a/app/views/admin/usuarios/index.html.erb b/app/views/admin/usuarios/index.html.erb index 1c0e520..d68d9dd 100644 --- a/app/views/admin/usuarios/index.html.erb +++ b/app/views/admin/usuarios/index.html.erb @@ -29,7 +29,8 @@ Nome E-mail / PIN - Perfil + Tipo de conta + Perfil de acesso Status AçÔes @@ -56,6 +57,22 @@ <%= badge_role(usuario.role) %> + <%# Tipo de conta x perfil sĂŁo coisas diferentes: o primeiro diz como + a pessoa entra, o segundo o que ela enxerga. Sem perfil, vale o + padrĂŁo do tipo de conta — a tela precisa dizer isso, senĂŁo parece + que a pessoa estĂĄ sem acesso nenhum. %> + + <% if usuario.motorista? %> + — + <% elsif usuario.perfil_acesso %> + <%= usuario.perfil_acesso.nome %> + <% unless usuario.perfil_acesso.ativo? %> + perfil desativado + <% end %> + <% else %> + PadrĂŁo do tipo de conta + <% end %> + <%= badge_status_usuario(usuario.ativo?) %> diff --git a/app/views/admin/variaveis_personalizadas/_form.html.erb b/app/views/admin/variaveis_personalizadas/_form.html.erb new file mode 100644 index 0000000..8e9275f --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/_form.html.erb @@ -0,0 +1,55 @@ +<%= form_with model: [:admin, variavel], + url: (variavel.persisted? ? admin_variavel_path(variavel) : admin_variaveis_path), + data: { turbo: false }, class: 'space-y-6' do |f| %> + + <% if variavel.errors.any? %> +
+
    + <% variavel.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+
+ <% end %> + +
+
+ <%= f.label :chave, 'Nome da variĂĄvel', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_field :chave, required: true, placeholder: 'telefone_suporte', + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white font-mono + focus:outline-none focus:border-[#f97316]' %> +

+ SĂł letras minĂșsculas, nĂșmeros e _. Na mensagem vocĂȘ escreve + {{<%= variavel.chave.presence || 'telefone_suporte' %>}}. +

+
+ +
+ <%= f.label :valor, 'Valor', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_field :valor, required: true, placeholder: '(11) 4002-8922', + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316]' %> +

É este texto que aparece no lugar da variável, em toda mensagem que a usar.

+
+ +
+ <%= f.label :descricao, 'Para que serve', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_field :descricao, placeholder: 'Telefone que atende o motorista', + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316]' %> +
+ +
+
+ <%= f.label :ativo, 'VariĂĄvel ativa', class: 'block text-sm font-medium text-gray-300' %> +

Desativada, some do editor e sai vazia nas mensagens que jĂĄ a usam.

+
+ <%= f.check_box :ativo, class: 'w-5 h-5 accent-orange-500' %> +
+
+ +
+ <%= f.submit variavel.persisted? ? 'Salvar' : 'Criar variĂĄvel', + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-6 py-3.5 rounded-xl min-h-[48px] cursor-pointer' %> + <%= link_to 'Cancelar', admin_variaveis_path, + class: 'px-5 py-3.5 text-gray-300 hover:text-white border border-[#2a2a2a] rounded-xl min-h-[48px] flex items-center' %> +
+<% end %> diff --git a/app/views/admin/variaveis_personalizadas/edit.html.erb b/app/views/admin/variaveis_personalizadas/edit.html.erb new file mode 100644 index 0000000..89d6b46 --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/edit.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, "VariĂĄvel #{@variavel.chave}" %> +
+

+ <%= icone :tag, espaco: false %> <%= @variavel.marcador %> +

+

O novo valor passa a valer no prĂłximo disparo de toda mensagem que usa esta variĂĄvel.

+ <%= render 'form', variavel: @variavel %> +
diff --git a/app/views/admin/variaveis_personalizadas/index.html.erb b/app/views/admin/variaveis_personalizadas/index.html.erb new file mode 100644 index 0000000..4a582b6 --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/index.html.erb @@ -0,0 +1,76 @@ +<% content_for :title, 'VariĂĄveis de mensagem' %> + +
+
+

<%= icone :tag, espaco: false %> VariĂĄveis de mensagem

+

+ Escreva {{assim}} no texto e o sistema troca pelo valor na hora do envio. +

+
+ <%= link_to new_admin_variavel_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil %> Nova variĂĄvel + <% end %> +
+ +<%# ── As suas ─────────────────────────────────────────── %> +<% if @variaveis.any? %> +
+ <% @variaveis.each do |v| %> +
+
+
+ <%= v.marcador %> + <% unless v.ativo? %> + Desativada + <% end %> +
+

<%= v.valor %>

+ <% if v.descricao.present? %> +

<%= v.descricao %>

+ <% end %> +
+
+ <%= link_to rotulo(:editar, 'Editar', cor: nil), edit_admin_variavel_path(v), data: { turbo: false }, + class: 'px-4 py-3 text-gray-300 hover:text-white border border-[#2a2a2a] hover:border-orange-500 rounded-xl min-h-[48px] flex items-center' %> + <%= button_to admin_variavel_path(v), method: :delete, + class: 'px-4 py-3 text-red-400 hover:text-red-300 border border-[#2a2a2a] hover:border-red-500 rounded-xl min-h-[48px] flex items-center gap-2', + form: { data: { turbo_confirm: "Excluir #{v.marcador}? Mensagens que a usam passam a mostrar espaço em branco." } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Excluir + <% end %> +
+
+ <% end %> +
+<% else %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

VocĂȘ ainda nĂŁo criou nenhuma variĂĄvel.

+

Boas primeiras: telefone do suporte, horårio de atendimento, endereço da base.

+
+<% end %> + +<%# ── As que já existem no sistema ───────────────────── + Listadas aqui para o ADM não tentar cadastrar uma que já existe (o cadastro + recusaria) e para descobrir o que dá para usar sem criar nada. %> +

JĂĄ disponĂ­veis no sistema

+

Use à vontade nas mensagens — não precisam ser cadastradas.

+ +
+ <% @do_sistema.each do |origem, itens| %> + <% next if itens.empty? %> +
+
+

<%= Notificacao::CatalogoVariaveis::ORIGENS[origem] %>

+
+
+ <% itens.each do |v| %> +
+ {{<%= v[:chave] %>}} +

<%= v[:descricao] %>

+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/admin/variaveis_personalizadas/new.html.erb b/app/views/admin/variaveis_personalizadas/new.html.erb new file mode 100644 index 0000000..eb750e0 --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/new.html.erb @@ -0,0 +1,6 @@ +<% content_for :title, 'Nova variĂĄvel' %> +
+

<%= icone :tag, espaco: false %> Nova variĂĄvel

+

Um texto fixo que vocĂȘ reaproveita em qualquer mensagem — mudou aqui, mudou em todas.

+ <%= render 'form', variavel: @variavel %> +
diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb index b4c93d9..6fb1a2c 100644 --- a/app/views/consolidacao_entregas/revisar.html.erb +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -80,7 +80,7 @@
<%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %> - <% if c.manual? && !(@consolidacao.finalizada? && current_user.operador?) %> + <% if c.manual? && !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %> <%= button_to remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, id: c.id, motorista: @motorista), method: :delete, form: { data: { turbo_confirm: 'Remover este apontamento manual?' } }, diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index 31eb1b9..f019502 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -56,7 +56,7 @@ <%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/ consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado. Só aparece quando o motorista usou mais de um veículo no período. %> - <% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %> + <% pode_fechar_veiculo = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %> <% if @veiculos_status.size > 1 %>
@@ -196,7 +196,7 @@ 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.pode?('consolidacao.editar_finalizada') %>
<% if @apontamentos.present? %> - <% pode_editar = !(@consolidacao.finalizada? && current_user.operador?) %> + <% pode_editar = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>

<%= icone :adicionar, espaco: false %> Apontamentos manuais

diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb index b5c50cd..2dbb1e3 100644 --- a/app/views/consolidacoes/wizard.html.erb +++ b/app/views/consolidacoes/wizard.html.erb @@ -33,7 +33,7 @@ <%# Caixa de ferramentas do Passo 1 — concentra "Apontamento manual" (busca pela NF) e "Adicionar motorista" num Ășnico botĂŁo com menu suspenso. %> - <% unless @consolidacao.finalizada? && current_user.operador? %> + <% unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada') %>
<%# Lista de motoristas %> - <% pode_arquivar_motorista = !(@consolidacao.finalizada? && current_user.operador?) %> + <% pode_arquivar_motorista = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>
<% @motoristas.each do |m| %> <%# Container relativo: o card Ă© um link; o botĂŁo de arquivar fica FORA da diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 6d3aa67..18b88ed 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -106,7 +106,9 @@ <% end %> - <%# Imprimir relatĂłrio financeiro do perĂ­odo/operação atuais %> + <%# Imprimir relatĂłrio financeiro do perĂ­odo/operação atuais — sĂł para quem + pode ver custo (permissĂŁo `dashboard.financeiro`). %> + <% if @ver_financeiro %> <%= link_to rotulo(:imprimir, 'RelatĂłrio', cor: nil), dashboard_relatorio_financeiro_path( inicio: @periodo_inicio.strftime('%Y-%m-%d'), @@ -114,6 +116,7 @@ operacoes: @operacao_filtro), data: { turbo: false }, class: 'pl-4 pr-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %> + <% end %>
@@ -147,6 +150,7 @@
<%# Card destacado: total consolidado e pago (consolidaçÔes finalizadas do período) %> + <% if @ver_financeiro %>

<%= icone :consolidado_pago, espaco: false %> Consolidado / Pago

<%= moeda(@fin_custo_total) %>

@@ -156,6 +160,7 @@ pago
+ <% end %> <%# Card 2: Total de entregas %>
@@ -203,6 +208,7 @@
<%# KPIs FINANCEIROS (consolidaçÔes finalizadas, jå com filtro de operação) %> + <% if @ver_financeiro %>

<%= icone :dinheiro, espaco: false %> Custo total

@@ -225,6 +231,7 @@

pendente

+ <% end %> <%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
@@ -244,7 +251,9 @@
- <%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato %> + <%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato. + É valor pago: fica atrás da mesma permissão dos demais blocos de custo. %> + <% if @ver_financeiro %>

Pagamentos por dia

@@ -273,6 +282,7 @@ <% end %>
<% end %> + <% end %>
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking: @@ -320,6 +330,7 @@ + <% if @ver_financeiro %> <%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
@@ -472,7 +483,9 @@
- <%# Por operação %> + <% end %> + + <%# Por operação (contagem de entregas — nĂŁo Ă© custo, fica para todos) %> <% if @por_operacao.any? %>

<%= icone :operacao, espaco: false %> Entregas por Local

@@ -719,7 +732,9 @@ })(); -<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período) %> +<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período). + Sem permissão de financeiro os dados não são carregados — o script sai fora. %> +<% if @ver_financeiro %> -<%# Rosca de pagamentos (pago x a pagar, por valor) %> +<%# Rosca de pagamentos (pago x a pagar, por valor) — mesmo bloco de permissão. %> -<%# ── Gráficos financeiros por operação ─────────────────────── %> +<% end %> + +<%# ── Gráficos financeiros por operação ─────────────────────── + Sem a permissão de financeiro os dados nem chegam à página: o controller não + carrega, e este script inteiro fica de fora do HTML. %> +<% if @ver_financeiro %> +<% end %> diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 5e6e8f7..31f641b 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -34,14 +34,17 @@ <%# Navegação %>
- - - - -
-
-144
-145
-146
-147
-148
-149
-              
-
-
-
::Encoding::UndefinedConversionError -
-
raise Prawn::Errors::IncompatibleStringEncoding, -
"Your document includes text that's not compatible with the " \ -
"Windows-1252 character set.\n" \ -
'If you need full UTF-8 support, use external fonts instead of ' \ -
-
-
-
-
- - - - - - - - - - - - - - -
-
- Extracted source (around line #114): -
-
- - - - - -
-
-112
-113
-114
-115
-116
-117
-              
-
-
-
@pdf.fill_rounded_rectangle [0, @pdf.cursor], 250, 26, 5 -
@pdf.fill_color(pago ? 'FFFFFF' : '374151') -
@pdf.text_box texto, at: [12, @pdf.cursor - 8], width: 238, size: 10, style: :bold -
@pdf.fill_color '000000' -
@pdf.move_down 34 -
end -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- - -

Exception Causes

- - - - - -

Request

-

Parameters:

{"motorista"=>"Bruno Corgozinho", "id"=>"9"}
-
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- - - - - - diff --git a/Erros/Action Controller_ Exception caughtQuando clica na opção usuarios.html b/Erros/Action Controller_ Exception caughtQuando clica na opção usuarios.html deleted file mode 100644 index 0aef099..0000000 --- a/Erros/Action Controller_ Exception caughtQuando clica na opção usuarios.html +++ /dev/null @@ -1,5441 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- NoMethodError in - Admin::Usuarios#index -

-
- -
-

- Showing /app/app/views/admin/usuarios/index.html.erb where line #60 raised: -

-
undefined method `badge_status_usuario' for #<ActionView::Base:0x00000000011f80>
- - -
-
- Extracted source (around line #61): -
-
- - - - - -
-
-59
-60
-61
-62
-63
-64
-              
-
-
-
</td> -
<td class="px-6 py-4"> -
'.freeze; @output_buffer.append=( badge_status_usuario(usuario.ativo?) ); @output_buffer.safe_append=' -
</td> -
<td class="px-6 py-4"> -
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- -

Request

-

Parameters:

None
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caught_quando Cria uma consolidação.html b/Erros/Action Controller_ Exception caught_quando Cria uma consolidação.html deleted file mode 100644 index 2d59e8f..0000000 --- a/Erros/Action Controller_ Exception caught_quando Cria uma consolidação.html +++ /dev/null @@ -1,3987 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- NoMethodError - in ConsolidacoesController#new -

-
- -
-
-
undefined method `new?' for #<ConsolidacaoPolicy:0x00007f710ab493f8 @user=#<User id: 1, email: "admin@gade.com", nome: "Administrador Gade", telefone: nil, pin_code: nil, role: "admin", ativo: true, tema_preferido: "dark", created_at: "2026-06-11 23:00:11.605256000 -0300", updated_at: "2026-06-11 23:00:11.605256000 -0300", login_token: nil>, @record=Consolidacao(id: integer, nome: string, data_inicio: date, data_fim: date, status: integer, valor_total: decimal, created_by: integer, finalizado_por: integer, finalizado_em: datetime, deleted_at: datetime, created_at: datetime, updated_at: datetime, route_ids: jsonb, arquivado_por: integer, arquivado_em: datetime)>
-
- - - - - - - - -
-
- Extracted source (around line #32): -
-
- - - - - -
-
-30
-31
-32
-33
-34
-35
-              
-
-
-
# GET /consolidacoes/new -
def new -
authorize Consolidacao -
@consolidacao = Consolidacao.new( -
data_inicio: Date.current.beginning_of_month, -
data_fim: Date.current -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- - - - -

Request

-

Parameters:

None
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caught_quando aperta as configuraçoes .html b/Erros/Action Controller_ Exception caught_quando aperta as configuraçoes .html deleted file mode 100644 index d75a32d..0000000 --- a/Erros/Action Controller_ Exception caught_quando aperta as configuraçoes .html +++ /dev/null @@ -1,4026 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- NoMethodError - in Admin::ConfiguracoesController#index -

-
- -
-
-
undefined method `admin_ou_gerente?' for #<ConfiguracaoPolicy:0x00007efdbb16abb0 @user=#<User id: 1, email: "admin@gade.com", nome: "Administrador Gade", telefone: nil, pin_code: nil, role: "admin", ativo: true, tema_preferido: "dark", created_at: "2026-06-11 23:00:11.605256000 -0300", updated_at: "2026-06-11 23:00:11.605256000 -0300", login_token: nil>, @record=Configuracao(id: integer, chave: string, valor: string, descricao: string, updated_by: integer, created_at: datetime, updated_at: datetime)>
-
- - - - - -
-
- Extracted source (around line #4): -
-
- - - - - -
-
-2
-3
-4
-5
-6
-7
-              
-
-
-
class ConfiguracaoPolicy < ApplicationPolicy -
def index? -
admin_ou_gerente? -
end -
-
def edit? -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- - - - -

Request

-

Parameters:

None
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caught_quando aperta na parte de usuario.html b/Erros/Action Controller_ Exception caught_quando aperta na parte de usuario.html deleted file mode 100644 index 0cc889a..0000000 --- a/Erros/Action Controller_ Exception caught_quando aperta na parte de usuario.html +++ /dev/null @@ -1,4027 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- NoMethodError - in Admin::UsuariosController#index -

-
- -
-
-
undefined method `admin_ou_gerente?' for #<UserPolicy:0x00007f710b57d3d8 @user=#<User id: 1, email: "admin@gade.com", nome: "Administrador Gade", telefone: nil, pin_code: nil, role: "admin", ativo: true, tema_preferido: "dark", created_at: "2026-06-11 23:00:11.605256000 -0300", updated_at: "2026-06-11 23:00:11.605256000 -0300", login_token: nil>, @record=User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, nome: string, telefone: string, pin_code: string, role: integer, ativo: boolean, tema_preferido: string, created_at: datetime, updated_at: datetime, login_token: string, sign_in_count: integer, current_sign_in_at: datetime, last_sign_in_at: datetime, current_sign_in_ip: string, last_sign_in_ip: string, failed_attempts: integer, unlock_token: string, locked_at: datetime)>
-
- - - - - -
-
- Extracted source (around line #4): -
-
- - - - - -
-
-2
-3
-4
-5
-6
-7
-              
-
-
-
class UserPolicy < ApplicationPolicy -
def index? -
admin_ou_gerente? -
end -
-
def show? -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- - - - -

Request

-

Parameters:

None
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caught_quando aperta nos usuarios .html b/Erros/Action Controller_ Exception caught_quando aperta nos usuarios .html deleted file mode 100644 index 3f57202..0000000 --- a/Erros/Action Controller_ Exception caught_quando aperta nos usuarios .html +++ /dev/null @@ -1,5440 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- NoMethodError in - Admin::Usuarios#index -

-
- -
-

- Showing /app/app/views/admin/usuarios/index.html.erb where line #57 raised: -

-
undefined method `badge_role' for #<ActionView::Base:0x0000000000fc80>
- - -
-
- Extracted source (around line #58): -
-
- - - - - -
-
-56
-57
-58
-59
-60
-61
-              
-
-
-
@output_buffer.safe_append=' </td> -
<td class="px-6 py-4"> -
'.freeze; @output_buffer.append=( badge_role(usuario.role) ); @output_buffer.safe_append=' -
</td> -
<td class="px-6 py-4"> -
'.freeze; @output_buffer.append=( badge_status_usuario(usuario.ativo?) ); @output_buffer.safe_append=' -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- -

Request

-

Parameters:

None
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caught_quando aperta o botĂŁo de relatĂłrio individual.html b/Erros/Action Controller_ Exception caught_quando aperta o botĂŁo de relatĂłrio individual.html deleted file mode 100644 index d5cc393..0000000 --- a/Erros/Action Controller_ Exception caught_quando aperta o botĂŁo de relatĂłrio individual.html +++ /dev/null @@ -1,4148 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- Pundit::NotDefinedError - in ConsolidacoesController#gerar_pdf_relatorio -

-
- -
-
-
unable to find policy `NilClassPolicy` for `nil`
-
- - - - - - - - - - - - -
-
- Extracted source (around line #116): -
-
- - - - - -
-
-114
-115
-116
-117
-118
-119
-              
-
-
-
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X -
def gerar_pdf_relatorio -
authorize @consolidacao, :show? -
motorista = params[:motorista] -
-
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista) -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- - - - -

Request

-

Parameters:

{"motorista"=>"Bruno Corgozinho", "id"=>"3"}
-
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caught_quando aperta para modificar os usuario .html b/Erros/Action Controller_ Exception caught_quando aperta para modificar os usuario .html deleted file mode 100644 index 9129811..0000000 --- a/Erros/Action Controller_ Exception caught_quando aperta para modificar os usuario .html +++ /dev/null @@ -1,6176 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- NameError in - Admin::Usuarios#edit -

-
- -
-

- Showing /app/app/views/admin/usuarios/_form.html.erb where line #47 raised: -

-
undefined local variable or method `role_options_for_select' for #<ActionView::Base:0x0000000000fb68>
- - -
-
- Extracted source (around line #48): -
-
- - - - - -
-
-46
-47
-48
-49
-50
-51
-              
-
-
-
'.freeze; @output_buffer.append=( f.label :role, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' ); @output_buffer.safe_append=' -
'.freeze; @output_buffer.append=( f.select :role, -
options_for_select(role_options_for_select, usuario.role), -
{}, -
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white -
focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Trace of template inclusion: #<ActionView::Template app/views/admin/usuarios/edit.html.erb locals=[]>

- - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- -

Request

-

Parameters:

{"id"=>"1"}
-
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caughtq_quando aperta para sair .html b/Erros/Action Controller_ Exception caughtq_quando aperta para sair .html deleted file mode 100644 index 9a90010..0000000 --- a/Erros/Action Controller_ Exception caughtq_quando aperta para sair .html +++ /dev/null @@ -1,2130 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

Routing Error

-
-
-

No route matches [GET] "/auth/logout"

- - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - -
- - -
- - - - -
- - -

- Routes -

- -

- Routes match in priority from top to bottom -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Helper - (Path / - Url) - HTTP VerbPathController#ActionSource Location
- new_user_session_path - - GET - - /auth/login(.:format) - -

users/sessions#new

-
-

devise (5.0.4) lib/devise/rails/routes.rb:378

-
- user_session_path - - POST - - /auth/login(.:format) - -

users/sessions#create

-
-

devise (5.0.4) lib/devise/rails/routes.rb:379

-
- destroy_user_session_path - - DELETE - - /auth/logout(.:format) - -

users/sessions#destroy

-
-

devise (5.0.4) lib/devise/rails/routes.rb:380

-
- new_user_password_path - - GET - - /auth/senha/new(.:format) - -

devise/passwords#new

-
-

devise (5.0.4) lib/devise/rails/routes.rb:385

-
- edit_user_password_path - - GET - - /auth/senha/edit(.:format) - -

devise/passwords#edit

-
-

devise (5.0.4) lib/devise/rails/routes.rb:385

-
- user_password_path - - PATCH - - /auth/senha(.:format) - -

devise/passwords#update

-
-

devise (5.0.4) lib/devise/rails/routes.rb:385

-
- - PUT - - /auth/senha(.:format) - -

devise/passwords#update

-
-

devise (5.0.4) lib/devise/rails/routes.rb:385

-
- - POST - - /auth/senha(.:format) - -

devise/passwords#create

-
-

devise (5.0.4) lib/devise/rails/routes.rb:385

-
- root_path - - GET - - / - -

dashboard#index

-
-

/app/config/routes.rb:13

-
- dashboard_path - - GET - - /dashboard(.:format) - -

dashboard#index

-
-

/app/config/routes.rb:16

-
- motorista_dashboard_path - - GET - - /motorista(.:format) - -

motorista/dashboard#index

-
-

/app/config/routes.rb:19

-
- motorista_login_path - - GET - - /motorista/login(.:format) - -

motorista/sessoes#new

-
-

/app/config/routes.rb:22

-
- - POST - - /motorista/login(.:format) - -

motorista/sessoes#create

-
-

/app/config/routes.rb:23

-
- motorista_acesso_qr_path - - GET - - /motorista/acesso/:token(.:format) - -

motorista/sessoes#acesso_qr

-
-

/app/config/routes.rb:24

-
- wizard_consolidacao_path - - GET - - /consolidacoes/:id/wizard(.:format) - -

consolidacoes#wizard

-
-

/app/config/routes.rb:29

-
- finalizar_consolidacao_path - - POST - - /consolidacoes/:id/finalizar(.:format) - -

consolidacoes#finalizar

-
-

/app/config/routes.rb:30

-
- arquivar_consolidacao_path - - POST - - /consolidacoes/:id/arquivar(.:format) - -

consolidacoes#arquivar

-
-

/app/config/routes.rb:31

-
- preview_holerite_consolidacao_path - - GET - - /consolidacoes/:id/preview_holerite(.:format) - -

consolidacoes#preview_holerite

-
-

/app/config/routes.rb:32

-
- gerar_pdf_relatorio_consolidacao_path - - GET - - /consolidacoes/:id/gerar_pdf_relatorio(.:format) - -

consolidacoes#gerar_pdf_relatorio

-
-

/app/config/routes.rb:33

-
- gerar_pdf_holerite_consolidacao_path - - GET - - /consolidacoes/:id/gerar_pdf_holerite(.:format) - -

consolidacoes#gerar_pdf_holerite

-
-

/app/config/routes.rb:34

-
- validar_consolidacao_consolidacao_entregas_path - - GET - - /consolidacoes/:consolidacao_id/consolidacao_entregas/validar(.:format) - -

consolidacao_entregas#validar

-
-

/app/config/routes.rb:39

-
- classificar_consolidacao_consolidacao_entregas_path - - POST - - /consolidacoes/:consolidacao_id/consolidacao_entregas/classificar(.:format) - -

consolidacao_entregas#classificar

-
-

/app/config/routes.rb:40

-
- classificar_em_massa_consolidacao_consolidacao_entregas_path - - POST - - /consolidacoes/:consolidacao_id/consolidacao_entregas/classificar_em_massa(.:format) - -

consolidacao_entregas#classificar_em_massa

-
-

/app/config/routes.rb:41

-
- revisar_consolidacao_consolidacao_entregas_path - - GET - - /consolidacoes/:consolidacao_id/consolidacao_entregas/revisar(.:format) - -

consolidacao_entregas#revisar

-
-

/app/config/routes.rb:42

-
- consolidacoes_path - - GET - - /consolidacoes(.:format) - -

consolidacoes#index

-
-

/app/config/routes.rb:27

-
- - POST - - /consolidacoes(.:format) - -

consolidacoes#create

-
-

/app/config/routes.rb:27

-
- new_consolidacao_path - - GET - - /consolidacoes/new(.:format) - -

consolidacoes#new

-
-

/app/config/routes.rb:27

-
- edit_consolidacao_path - - GET - - /consolidacoes/:id/edit(.:format) - -

consolidacoes#edit

-
-

/app/config/routes.rb:27

-
- consolidacao_path - - GET - - /consolidacoes/:id(.:format) - -

consolidacoes#show

-
-

/app/config/routes.rb:27

-
- - PATCH - - /consolidacoes/:id(.:format) - -

consolidacoes#update

-
-

/app/config/routes.rb:27

-
- - PUT - - /consolidacoes/:id(.:format) - -

consolidacoes#update

-
-

/app/config/routes.rb:27

-
- - DELETE - - /consolidacoes/:id(.:format) - -

consolidacoes#destroy

-
-

/app/config/routes.rb:27

-
- toggle_ativo_admin_usuario_path - - PATCH - - /admin/usuarios/:id/toggle_ativo(.:format) - -

admin/usuarios#toggle_ativo

-
-

/app/config/routes.rb:50

-
- admin_usuarios_path - - GET - - /admin/usuarios(.:format) - -

admin/usuarios#index

-
-

/app/config/routes.rb:49

-
- - POST - - /admin/usuarios(.:format) - -

admin/usuarios#create

-
-

/app/config/routes.rb:49

-
- new_admin_usuario_path - - GET - - /admin/usuarios/new(.:format) - -

admin/usuarios#new

-
-

/app/config/routes.rb:49

-
- edit_admin_usuario_path - - GET - - /admin/usuarios/:id/edit(.:format) - -

admin/usuarios#edit

-
-

/app/config/routes.rb:49

-
- admin_usuario_path - - PATCH - - /admin/usuarios/:id(.:format) - -

admin/usuarios#update

-
-

/app/config/routes.rb:49

-
- - PUT - - /admin/usuarios/:id(.:format) - -

admin/usuarios#update

-
-

/app/config/routes.rb:49

-
- - DELETE - - /admin/usuarios/:id(.:format) - -

admin/usuarios#destroy

-
-

/app/config/routes.rb:49

-
- toggle_tema_admin_configuracoes_path - - POST - - /admin/configuracoes/toggle_tema(.:format) - -

admin/configuracoes#toggle_tema

-
-

/app/config/routes.rb:53

-
- admin_configuracoes_path - - GET - - /admin/configuracoes(.:format) - -

admin/configuracoes#index

-
-

/app/config/routes.rb:52

-
- edit_admin_configuracao_path - - GET - - /admin/configuracoes/:id/edit(.:format) - -

admin/configuracoes#edit

-
-

/app/config/routes.rb:52

-
- admin_configuracao_path - - PATCH - - /admin/configuracoes/:id(.:format) - -

admin/configuracoes#update

-
-

/app/config/routes.rb:52

-
- - PUT - - /admin/configuracoes/:id(.:format) - -

admin/configuracoes#update

-
-

/app/config/routes.rb:52

-
- admin_auditoria_logs_path - - GET - - /admin/auditoria_logs(.:format) - -

admin/auditoria_logs#index

-
-

/app/config/routes.rb:55

-
- api_v1_dashboard_metricas_path - - GET - - /api/v1/dashboard/metricas(.:format) - -

api/v1/dashboard#metricas

-
-

/app/config/routes.rb:61

-
- health_path - - GET - - /health(.:format) - -

Inline handler (Proc/Lambda)

-
-

/app/config/routes.rb:66

-
- turbo_recede_historical_location_path - - GET - - /recede_historical_location(.:format) - -

turbo/native/navigation#recede

-
-

turbo-rails (2.0.23) config/routes.rb:2

-
- turbo_resume_historical_location_path - - GET - - /resume_historical_location(.:format) - -

turbo/native/navigation#resume

-
-

turbo-rails (2.0.23) config/routes.rb:3

-
- turbo_refresh_historical_location_path - - GET - - /refresh_historical_location(.:format) - -

turbo/native/navigation#refresh

-
-

turbo-rails (2.0.23) config/routes.rb:4

-
- rails_postmark_inbound_emails_path - - POST - - /rails/action_mailbox/postmark/inbound_emails(.:format) - -

action_mailbox/ingresses/postmark/inbound_emails#create

-
-

actionmailbox (7.1.6) config/routes.rb:5

-
- rails_relay_inbound_emails_path - - POST - - /rails/action_mailbox/relay/inbound_emails(.:format) - -

action_mailbox/ingresses/relay/inbound_emails#create

-
-

actionmailbox (7.1.6) config/routes.rb:6

-
- rails_sendgrid_inbound_emails_path - - POST - - /rails/action_mailbox/sendgrid/inbound_emails(.:format) - -

action_mailbox/ingresses/sendgrid/inbound_emails#create

-
-

actionmailbox (7.1.6) config/routes.rb:7

-
- rails_mandrill_inbound_health_check_path - - GET - - /rails/action_mailbox/mandrill/inbound_emails(.:format) - -

action_mailbox/ingresses/mandrill/inbound_emails#health_check

-
-

actionmailbox (7.1.6) config/routes.rb:10

-
- rails_mandrill_inbound_emails_path - - POST - - /rails/action_mailbox/mandrill/inbound_emails(.:format) - -

action_mailbox/ingresses/mandrill/inbound_emails#create

-
-

actionmailbox (7.1.6) config/routes.rb:11

-
- rails_mailgun_inbound_emails_path - - POST - - /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) - -

action_mailbox/ingresses/mailgun/inbound_emails#create

-
-

actionmailbox (7.1.6) config/routes.rb:14

-
- rails_conductor_inbound_emails_path - - GET - - /rails/conductor/action_mailbox/inbound_emails(.:format) - -

rails/conductor/action_mailbox/inbound_emails#index

-
-

actionmailbox (7.1.6) config/routes.rb:19

-
- - POST - - /rails/conductor/action_mailbox/inbound_emails(.:format) - -

rails/conductor/action_mailbox/inbound_emails#create

-
-

actionmailbox (7.1.6) config/routes.rb:19

-
- new_rails_conductor_inbound_email_path - - GET - - /rails/conductor/action_mailbox/inbound_emails/new(.:format) - -

rails/conductor/action_mailbox/inbound_emails#new

-
-

actionmailbox (7.1.6) config/routes.rb:19

-
- rails_conductor_inbound_email_path - - GET - - /rails/conductor/action_mailbox/inbound_emails/:id(.:format) - -

rails/conductor/action_mailbox/inbound_emails#show

-
-

actionmailbox (7.1.6) config/routes.rb:19

-
- new_rails_conductor_inbound_email_source_path - - GET - - /rails/conductor/action_mailbox/inbound_emails/sources/new(.:format) - -

rails/conductor/action_mailbox/inbound_emails/sources#new

-
-

actionmailbox (7.1.6) config/routes.rb:20

-
- rails_conductor_inbound_email_sources_path - - POST - - /rails/conductor/action_mailbox/inbound_emails/sources(.:format) - -

rails/conductor/action_mailbox/inbound_emails/sources#create

-
-

actionmailbox (7.1.6) config/routes.rb:21

-
- rails_conductor_inbound_email_reroute_path - - POST - - /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format) - -

rails/conductor/action_mailbox/reroutes#create

-
-

actionmailbox (7.1.6) config/routes.rb:23

-
- rails_conductor_inbound_email_incinerate_path - - POST - - /rails/conductor/action_mailbox/:inbound_email_id/incinerate(.:format) - -

rails/conductor/action_mailbox/incinerates#create

-
-

actionmailbox (7.1.6) config/routes.rb:24

-
- rails_service_blob_path - - GET - - /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) - -

active_storage/blobs/redirect#show

-
-

activestorage (7.1.6) config/routes.rb:5

-
- rails_service_blob_proxy_path - - GET - - /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format) - -

active_storage/blobs/proxy#show

-
-

activestorage (7.1.6) config/routes.rb:6

-
- - GET - - /rails/active_storage/blobs/:signed_id/*filename(.:format) - -

active_storage/blobs/redirect#show

-
-

activestorage (7.1.6) config/routes.rb:7

-
- rails_blob_representation_path - - GET - - /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) - -

active_storage/representations/redirect#show

-
-

activestorage (7.1.6) config/routes.rb:9

-
- rails_blob_representation_proxy_path - - GET - - /rails/active_storage/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format) - -

active_storage/representations/proxy#show

-
-

activestorage (7.1.6) config/routes.rb:10

-
- - GET - - /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) - -

active_storage/representations/redirect#show

-
-

activestorage (7.1.6) config/routes.rb:11

-
- rails_disk_service_path - - GET - - /rails/active_storage/disk/:encoded_key/*filename(.:format) - -

active_storage/disk#show

-
-

activestorage (7.1.6) config/routes.rb:13

-
- update_rails_disk_service_path - - PUT - - /rails/active_storage/disk/:encoded_token(.:format) - -

active_storage/disk#update

-
-

activestorage (7.1.6) config/routes.rb:14

-
- rails_direct_uploads_path - - POST - - /rails/active_storage/direct_uploads(.:format) - -

active_storage/direct_uploads#create

-
-

activestorage (7.1.6) config/routes.rb:15

-
- - - - -

Request

-

Parameters:

None
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caughtquando aperta o botçao de gerar holerite.html b/Erros/Action Controller_ Exception caughtquando aperta o botçao de gerar holerite.html deleted file mode 100644 index 58bb3d2..0000000 --- a/Erros/Action Controller_ Exception caughtquando aperta o botçao de gerar holerite.html +++ /dev/null @@ -1,4148 +0,0 @@ - - - - - - - Action Controller: Exception caught - - - - - - -
-

- Pundit::NotDefinedError - in ConsolidacoesController#gerar_pdf_holerite -

-
- -
-
-
unable to find policy `NilClassPolicy` for `nil`
-
- - - - - - - - - - - - -
-
- Extracted source (around line #130): -
-
- - - - - -
-
-128
-129
-130
-131
-132
-133
-              
-
-
-
# GET /consolidacoes/:id/gerar_pdf_holerite?motorista=X -
def gerar_pdf_holerite -
authorize @consolidacao, :show? -
motorista = params[:motorista] -
user_mot = User.motorista.find_by('LOWER(nome) = ?', motorista.to_s.downcase) -
user_mot&.regenerar_login_token! if user_mot&.login_token.blank? -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Rails.root: /app

- -
- Application Trace | - Framework Trace | - Full Trace - - - - - - -
- - - - -

Request

-

Parameters:

{"motorista"=>"Bruno Corgozinho", "id"=>"3"}
-
- -
- - -
- -
- - -
- -

Response

-

Headers:

None
- -
- - - - diff --git a/Erros/Action Controller_ Exception caughtquando termina de criar o usuario motorista.html b/Erros/Action Controller_ Exception caughtquando termina de criar o usuario motorista.html deleted file mode 100644 index 92d39ef..0000000 --- a/Erros/Action Controller_ Exception caughtquando termina de criar o usuario motorista.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - - - Reem LogĂ­stica - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
G
- Reem LogĂ­stica -
-
-
-
- - - - - - -
-
- -
-

- Novo UsuĂĄrio -

-

- Preencha os dados para criar um novo acesso -

-
- - - - -
- - -
-
- - -
- -
- - -
- -
-
- -

UsuĂĄrios inativos nĂŁo conseguem fazer login

-
- -
-
- -
-
-

Credenciais de acesso

-
-
- - -
-
- - -
-
- - -
-
-
-
- - - -
- Cancelar - -
-
- - - - -
- - - - - - diff --git a/Erros/Imagem colada (2).png b/Erros/Imagem colada (2).png deleted file mode 100644 index f584995d64d2ca9f6fc46661d9fe0047289b1c6e91b3457722f098912b4a588c..0000000000000000000000000000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 135171 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
91R; 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 diff --git a/Erros/Imagem colada.png b/Erros/Imagem colada.png deleted file mode 100644 index 5c2ba27407dbf862e71a7d46dca0d0502c5a24e6906502a9b0c3b408014da47b..0000000000000000000000000000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94174 zcmd?Rby!qw*ET*V64IRm2t$Y zpXYv``+k4N@%_H{|91}HV1~VCUu&&%o$Fld+C!9zvMkPH%EurO2uEH{N(}@;ivodA zNifiWR~&p8@xqzqGvY5Jm)3yjeF5|V;>6-zP{%QDqka|j0Hciu)G$) ze?%a0v6^qvmu$50<5;|3IMzu{C0;m~z98ErC`by0L`sWv*4%^Hy}VlQ^t!Mururba zg{Ps%Z>eUc%K$z-z4#`~ar)r6>hKt&Us}OWn$Vb1=)?y+XGfay`caws<6`SqNs!2b zNHuHhdNvp@`{yZZuUvHfnI{;-fL&+KUF9_!! zHX0bOumLl3H}mzL7pk!I@GAY-bqCD*u}g{i1Wi5(utlv5!Zz%%NBgM0lDDe?FbI9n z*+{#WtCkI`P7Aw92M6>)k%?~jF{(t*))tEn$>T?INul}qPgtS}1G)@mK8#wZvZmyh zKjzN(R0Uj^SUNU7{*%^T7($>_ofTi2K)iPJ$cwtXyg;2*0w15o6(k2)!w)6jamOjP z&hPG~H8nL=A%4_mAX_(!HREoq%>$$)yn9SzlLAyOz{fD<7lToi6$hLmH-0UY>1Bv#8Zn6ZMc3#fDeQM zEKHSns2&v#HP+_yTb6ZnhM8;&fGf+V+(ZR2aV$Hb2 zd^&Mei4(^*so=JpOXQBR`~y^oxT66t(+;lx>r-g|no^?@f^e93MF3Y_L%K-qeK~8X zW<}&60`Lc?5>bXCX&2rX6E5H?KKhdMiGLtgrvA#%@MY3h2=yT5ePltXmtGj#aOpI! zRw@aNpR_8agw+$8cgC5d0Lp+9sx(8&%K^{Uvq|EkVg!R=oP9ZJY^1B#V&&5|vrMpY z;7A`IpAWB#IJvol?f7B<_$;3Tz*+I`#r)+>vvUd{OoBc!$e6s=9;=?M*IhKd7-j5O z6@Va|Ph?C|ghmzs@TRvMQ*va#RAd3hj5`$>8MxTG@7&*vn_|lcoV)oVSRs@S6@Ax)6`RbTvjIf$2s)Ys7<7N&5d=*lQUV{Op|L8U z?6sCz90e>*?|F*=X7IR6;=}T3RzKPy3)c_knDK~LQ#PeD?pmpssvlRbfpvmFQ@pF* z2IM12kUpq=dU2RK>mwK^<@Hm6TmW1J~`MRU`S*cC#G z(01Vw$MidO_nUA5SuKHYlqrLt-;_u-2Y~u2DcF=kuhje^z-dIisg>516Prh5S*=Xn zYa}x;bGeFz6(TRgogoDzHbg$o|5=Z--GvyC5BV$r-m$S^;sB8B;0}7V>jO9}DB7v{ z)-NIpQ0ZE%P&Y$y;n3B#=l@T@{1=>bd6&Mp3AxH2e^!PN0(qQj9x>NW=SxDJ?xP>V zf=2a3o*Nq$WV~fu+@w=gpu!?fZ0e+s7j7(`6l_8u7)-zzslofQWT z%`aya8Iu+>w93A~HK`-FgNL(3uS!^3$b>vx6UR3JAnCM7r}d%=O+p;;CAQa@I=vWk z#vKSHCpOWx8)X5@i=Q)qvA~t}W^nMlT$xE9KPoMcuB)T-rA!zE(*6@mFD419yr>9y zFbH6pF4}Cl-mkf#<>hiEQve)_t@-K)>D4tPs){8vG^{cK!lAT}dy?`xEbwr9+d3Qa zLLffGLWTk)r5Xxgkqrw#7$-K6xQa|6uZysyRber20lWj*5g7Q=t&_%pGXyL4v4)0G zCMm!P)|7z#0J%leGh~ed1P>rjKlI5vP;|acA`cP#@`D8kflEZi}C-kF5O}vl!LZX zXfR_;?vtKKf{OCh>_ISo2-|R+oxf8T;4Hcp$>Hxw_T_1>|F;mE*+VGaJ z)&{_~;|>-T6$OE`abF+$(HeVHYd=W&YBtWc&yD>M$b@*)LajaNDow26QJ~gzR!5*W zUS>rX7`eF-ha1zeoId9EsnYJ;aAb{+)=`q=&Im5H9@;!snKmksQ&X8_mgi#HGwv$_4h+{8q2|`Pn=to9r|YS0-f!zJg;LKVETYm2P&4-Qp3^$K;|ZB% z6STDuw-*+O1ejj4LLx~j-Qoj9)pWTNH9z)+P1F3)ngvj7eqV}|Pe1mf1!CQG*n51# zxm(Ke7=R{V^5M1d#)7jRz%ST1IQca-w3(V%%4n)zkVww@IX#9L%8%mnP#ijzx0ULw z05P$o4Ff*`oEZz6aAyGUiL+~b1o!wL2QkuS0_csEPxEI4!m4yQuX z0cr!ha<>{Yr>NSUqI1TnG7GnpNGb|v=UnQYiLM|RMWG(IGJE0 zo8o21u2f`tIOsgiCdrHaFuFioLjw>ryoAA=*bE{=qN376E-LgtMXb%W9n9pEN^L$g z*+l~-Yi1Wn^~vxMV%4H*fF&Gu1W1%FE*_E%5=R!H0d~ z(i&^W2WKI{=58M4ZZqm;qBONCUlppR`q(+W>kgEwF;|!(SJ1#C1{@ftN|h#+wlWGp z6v}>>xto$npJh^2rB$A9_Cj+8T0Y9mdPu8s^6^Cj#n|F9mkt%7mubOySG0>}9xhQV zHlz#$yvf0$l709UcgB~kEi2PJ46Aqz?Xr&(Gc$N&SOZ>m2EVdU5=S@2sR!!>!_9bx z_pSp>1FcrWs!B@GPuqZ6Q3(9fMx*zcdhq?G_p0E8U+|}lL=6p+BnaSSzVH4|UEX7I zAI~9Q8J4M29<9)2C<0363BWiryGH*%I^e&h_kI(;qluy6J!8D7jX|

Caba^mrRZ^$ri|ddkG`CTV3r3vGltBBy>fa$93u@> z`Aa{s5n{d&;lxN7Gz3fhIhmdcL>^aRCzHZ{5VL-+RS$RFg`$8wb}U_yui=Xtxse3v z1Ug-4#+il6i{WiR%Tk(Kz9ttbrl8(5+Uvv4WDFcV&q4&Dk-ckn?kM7n1iVm)ttv~O zh{1tTKn{o+AgMo?3u4c&WOKq?hJ89|4qzJ|x~X8an(AE@4aSiUzJ~2ltVnZhq)5%t zqVk2AI5)*dAHIh5rc6?cetrIQ)EXd~fCG{sPZH&TVr|yYiljG!^YJQ$l(QO(w^vgc z_ywO$ueQQaQi zudAy=ekPLU!loZ{&EG{+*LZI)Y^7|5hDzk|$lO~Z2+iE>VQI5RmM~0XQqXwiQ@h+{{;^LeErBOiXKy$$VfjhbVNslu_ zF%*si>P?SzFcncFNF|BArqJUJQR@Z(DlT56X05nGs`oJjy8}=KefuiY0)8*kF>18= z<`-swv~1EL={gL{Fi|Cj#RM6n*8N~JwqL@}yl%K8eueKSPaaP3Jk9)nAm0C-v>O~t zQgA4TD)9@p2^%ymr(i5(2i-9X#si>w0d32X*Mn0Z@F<`&r-E3}dTrpEUQUn0R*WEo zQAmi>qS1}8K3;um66DoP91``TIc5RN1JIiDi;*ET^S46(>J#xAETZWpr*@2gjvWV& z*z(xUj+B0@kJVvM?|F@HV$Afz*{%gZDn4+rZMU|%;}B;ylutTUeDllKR}hhxDwKa6euX(zLLDzq&~KXPWq?G=W)34twIvNDXdal4+ei~^#& z7s}6*GkQx9^Jv{!Lh~IChfDP?(0K9216>fb5I%H+W*R;Zon`^!FbO9^xE2P3=|b02 z3RuGJJ*m;5`#sPQwN^mEvOO~v&nl)6lOCt8n5bvNcvY#|MV2jDP8s`%Qc;Smu_41< zse}h;lsnfMY|CEXCm)4Y$%wA$pq6;n$8h0g#>XYf}l0L4ES+R}$$73r_>OpXQIS&|} z`Nyu}V}gG?*g=yHt<>LZ)NALV&2GB?I4RO97o(e0x_dU=DCVCplZKNMN&WMIg;zpH z|Br`GP&(uvPwD^nJ%?H)$*E|F6E~X?{Ws4+tPKL~w$;quh*5ln;fB=jLnGH+da0_h zn$_O(3q3v_3k*M62`OE4Bg*dWy|i1}mHE9AQunJki+)(0e0hcPn-5OsKf<+jyGEwkdZvSI=tj_?X5E!3|BnDS(v1u|xrV**d(dfk zt#gh#;Uz^dhvx?x4m=l@iy`yWmtJ(1t$dzBAL!G4QlxG3;_nL+Oi;~aGe>>ghf0n; zA{)6l{{1aQ%kTD7+%85r?;n?tItl0AgpHcN5ylB-^JJTG=mw`$)P#3>u#4zNG`c-q zYd?ry(6*E?XyvZ2ZriOM%J!l!pD}znGg>s0zd9B`x{>@wd6%cld{p(Hz&D`X5Rw0= z*HCOaw>8rhJ`s4CHpIPIlx8G?1ifCq7bCqtDUkzvoBg^S@m(H<5V)I*T|!Q-58#ud z|3I{le#SA^!e>SW`WpY?zS;G(gnN`R*Z|M>AV>1V{-fqj`{$Xm|1hSJZq%DG@vv=T z=F~^_L21sr8p-bafG?iQqV;-_gR4|%f5mU$@=C0g{stohEW& zyM&=@kOtq^ceTbVI!pfq2#S+_wGgf$%HI}>!iGj=OT$o9fqeD`4#P5 z(C?IYeE?Fe9jEMe@0ZR$h+h60sweZ9$Na9q0fd!MH186d@OmHpdOfY~E|LKR%1Bo3 zD+s(lAF|}j_2&bEouI`O(&xSTAcphg>ze|PVRrCx;Xyr?nk<;) z_F&8(S2XVi;zD~VXV%&=%J-pp4(-q38^4mdE_(6$7F+6kA4(JW8(veo(B3(H_BRoU z|CRYdu39@}UU)fb2ZvwLJ1d6FAaY^Ok|9;mqod@Pi^|fC1q4qJ^-t;*J*(~+BI_1d zKO{AycnAdU$7)1(8z(8dGST%FCo(yYuI~OWaUHqmJ~hH%H)WMHS*NE`k8|7+lw) zSPZ1)H~pNaQ@@*&-R>g!z_3yc=Z1P>%OT=)MB2FPqQZ zWnRgTVu04-`z|*Hw>C$$Jl7qU6f%7{@7nbaHG}1aV2j3 zBwo&AqDSAZmu8L(*5_@H)^dH<`bEz}Pfw;+f8PL^c^U8NR(WVmx#zyS;xPZ?C>KAW z@cfx!#y1OkATQqPaNOpe({-IMfrsB7`Y}%;G~P7fsFoab9DnXEJZO6sO=d8AsZmUU z&C?tp>NmLU)qSzfAsIQyd>deXy`Lz2dxAOhG~lcO!TdnCSsworkk_BP1#^qKZx}Vx zUcbF#I6Hr{?Hfa;&s&75Te0`FM10YC@aP>rDz={gY0Gv0Vhcf+x%;i&a#0$6-C@^T zn?c2S<-fgwHJpvdzv-|L8Mm1m^Pcvu*7moAUL(e~XXmq0q;>5~mEYl1f3kNdau^w;fo;un;~sxyeiBJfhz<^2;_ySM(|cZTV6yCQEP@oj&`}{i@u1ik zy+F`%xg7O#(gadZ_JAe6qW0R{738Y_SUykt|pZN2LXt^>TekY|z~)a4jRwOyDH z(8OMk?Ou{&Byutyy1ZOj>w<=^dVgAD+>Lxd7Y$hQ5X1s^h}nw}`*GCzE%1){^*aO1 z>%+i^`rEUiJm2Ts%-NjVXLguJoo5d~KMvclfGX1mrU|dHetQ*r`Qt6;K_}VSFb6}b za>VAFt8~*3vyR>IV=2`w4@}bxaXqJ)FrtIMbX^zRw=ah^+Rarwi=vMtP>wTNHn6k})7INFiuFV&1+vA6b{sc{OK7`1_|WEa0FOGZ5x=qFVX#WL(ZY-hWyn z2=NJ6M_grvUAz4+_p{r>hz=VKvy6cI+gW6_XP5@#*?bfE^-N&c?aeXy#n(4XOGcke zBl&k717TbIT?E=2@p1Vsr_+fL0uYO1!0~5!dOCq>sxcufgS20gW1C{=5~&Qsub1!m z!a3Sl{OrY!Q2BDbnL2DpTAyPUv5z*h*?ArM<}bUBHHz{7?&mPss~-l9?RwR~KZG&> zP8qm6HDu`4y}vy?ntYFLQXxwt!Ip=&9CKKQ4%DuQ%dJA!Nsqr(rH;S`qdY3;=hPch z+A))5CfEA4{1)BGJ5SVN4Scpo0v3*uYhu?@q^%4L z;EH}1LC8$yw$@Va-j>Cl?(&Y1U)EXHvKw2BdISh~Gpj)%2E&V2Gsc59_s1_e@3-?l z9YEos`*UvJ1tlJ-hrFxttR{|eheQT_s<-Ags0vrw%$a#1fvPH1Up3zkrrMQLdl6^g z^R%8h2X*xYpNP(O^t0j|Gp@S}*;xNwUH#>lqN&KkR!g&t2IGgX-|0=b`+J)GQF!5N zjA>G-%y=SBlaPu3PhtWhE^{G~K)t4$W_q^<9e?Dyuxp)N#S@Mbb$cdha)0~e?9|<_ z8u_O@?N-&^{Z6yKFLs32&Q!;-C8x6gIU;|r_Tev$icggWIOdxK~)EV;Lz zgY+FR6}vQ>k)i^zRA@d)9hFJlK3~GB(b;e766QrkOvnnC=M~#rG~|U{{z`spuuMgj zHR*G@d>=4xU$*Rj-g4-!5V>=I9teg#mFYTc?%tOpR1(-!J$B`>`fn+6oC~qpq(Y08 zrFnle#K3ZojKGIrZkr}Z9~Py(Vk3o}qRPLz%XY2ZUU0R%(KT`8c)XdH@WVL59s3a4 z+NN=<7)3|-vpxr8Bzk`nN?T{>Wo%k)I!!8Pc4|*bdgrXP&6l@8qWfuOaf2LbmV%ab+@i%v(jyNZ=qz+5$ z944^24i2o~;w4;DEIX@o9?7&wN@}?rk0zdfoUX83NCj#?dCQf}7nfG&z?rLE!&P83 zdDt`)8)!L?it@_$ck!?uUzHy`_r$j7Pql`^=@Od9{AkbG+-q|KD9d1Yc{a9&w_a)a zr?2#rRPWgAru?$xvzTrabsx=r>>4`;EhqSSu)=7&NY9!7Q@RN{p(EBwEbbSpT5jtp ziRMkZ4!tX7CCXQGSEA=V&6Ptp0{gC)iW-0PPvDI#uf(sm(nX(VJKdK!v4gY`x98ok zX10@?)qTAfkD7SY^RRT$k0(}^HtaO5yQgCM zg8;IJf-#C|Ek53ZEi2C6r*k)zI%hJ#TH$%Cq-4YV^M9mu`UbE3FT2_CI<~GE1N^lk z%uQnyP+9lOB)SKn8np~n;UwFi;*{i(Y*byyS0B61JLBE?OLVM@E1C18)_NYoW&p+M ze!gfdc4aklnv6~>vU(oX*$LjYYF>_ir!3ZLy4X5vaNWAOvMUrSMX#4S>uLBUN#5(! zXYi`dkLF19EdA7@wypMmhI&a&fvZ^F6w}FrFKkfvSkZwq!K4+AY#ZDZl@^7vOq1!R@0bKI)0z-7z@k6rCphMz$S$yU4EFe~|mQ zZcr#yULlpvd)7Glet>Q2psm7G`;alsZBh1*a5TyDXS(UChF@PGa-I#C4CQ*ve}oHa zbeT(ZZ{`kh*VW)%CiqwGUWw-2@tS0eR5l};1}Zd-#crI;GJuW=X!aJ}{p*^^MDs4B z27g~22JkcQGyKQX1!3mO&!YTb;`;+s!h{6drtuOtb%QE^TuV<7d0=_#bCLR}uA6qJ zx}E;|Js#cN&t?xl!_&=|c_;j)>@esH?`fOCdY}vG_1h^!in=#1j^F1T(xoaYJMUV& zS&w)rwOU9UHakQ1{9o#G!39g^c^CNx(Hrr-UCFCiQ`L@(HTmuu_*rqnOPXmahoIZF z7*Mcaj1NaA@s6_a=u&tidFzQ91W%N=zH)OaFI=D>kLRMb{{R)m`_sLKtkZTLRlo@By-rUv?W46Af@j1kZ8t70g@piH9QmL37d)#L zXUj{dv=uuek9~RPES9DCcltW2xMo5g1SN9#W0n=hM?=Ix;y};SX?XYhV|U3_Ltd{{ z1!RYBP~mE;&{*^c6)0}=Ii7uB%HYeAm+sZ)2c8V>UkLuRO|x)eXGVpzozHW>rgzD2 z2l&U&PitgTna|*O!vWX2J;y<_KVrHnOY#oV$K>ZcbnQ+r5Kcd9B0iPlUtbU22W!t7 z98buqwx4Q#P7q|O7dc%@By6jHI zm*Z(L=wj)FZ_rAuO$syxzS~7x-D{8K6w)4mVk2n70l@>#BU2{ zS}5kn9)khQ(+3EZYDWcyR5vC3-eXR;yGukK!{70cn&RCAbVjM2xAU^UD$sUb#Ph6= zc^46ebF_e{lm$?_jNSAt%^pw7by=Gr8>axZ&3hnpu*HG~3=)bC{Yns>H?5n>A`z2p zNOr6mYENpL=0(0$pMi&XMq9~w*(cZGFV6+Z@x%n~4h3-sx-OMEes`U_cccj(Ndawg zGQSTYK)70gClj3aH=CF<&jRnSOZ8kwE$)|g!`iKh46Y&q#rKP|K+Ab<{=SPse(!3875G0SLBLMzDz>1PEX*CO1hf^23eP1rW=#o}Z8=zk4=#389OEC>sJ-tD5Upg+ z6$cnL;;h23mwyzqyZH~`!PHU*47Be7qygO&kdxh-A&%OsK4;7K@$T*W;ed7Y-a}75 zysHVP8wSdJ*jbDr>N1Z|v=%q!{ux}DRsYw1bHoezdQ(^L%<$gxZd#y?d3E;jE&j6W zo5&c_-im1>(Ocf~m)8`mFK?gz$ngmF7z8Jd&D+k%7+it2dUsxT1<7UCmdw(S%?49A)%M?5Wl~1fiKz+&B7NX5TGI&3w)|9?=(sSufGb>Y2f{>@&f`=M%gO*<)j# zu8~_fE6av{1k!`RjbtBQujNwG7cI(7gGKWaly?}IbPGVECDtr=-CMJ31=kTdGA&4m9KqmH(ED-p6cv#~W-B2Vov zMQ}ONRGsueJz`DsE%F73!GYShrVqAt*urb@u#Fs*V5;lfnp&jP5qif>qdeT~NX*r>J#zvr6{nfW) zr|;1uQ#v))Vdkr~uOAJwVLfBRN~K#dm+p;;4mSMdLcaZ5>R6bS#Lvr1!0)zheK>#6 zM8MmP7zj&Cmr9SL<@jn-Of_P$h)^?=Nl&5&I4kSe`{rrlR$D;dv6ESAtiC(AGd`qp zw*V4No(fsWQ$~1ZU@J;&U1a~d7HHAwU&;)ZhQ?Lynn)$ps-`-73dOzrJcTS-^btSt zr`9Q384Z8Go*GtwFj7f9sxMeMl6v52$xRdx(Ch=gp0Uw#(AKhplczxJKA7L|W%V3Z^{k0J1e%vY7|FRv1272 zngRM)(pCbqUfXHd)_7*N54$20sqZ&ht(j3e=*a zWI-^;%2*wSH@SF2SVIASnSL|3GtJy`rDn+`8~EZ#xoK#tcp`**XFXL6z^9cuH)_zi zZZjZq+`{|1GKX4OdFv*yAv3N6G8+@4x1289Tmm z{)rePG)`>r{X-+EBxG>;+L;}xt?mK(lqmYUPEhtE=Qyx&Zxr_836WZ+ z@x{bTAmfX>s3?%50#NnGGwoJilaPm-_VdKw4YdfRvssNE z$vLx{X+v@wA3`%I!A=S*g{+oIJ?Q>s!H^>UR@A~!x0?pe8$NjJO8=gN&0wL8ND!(oQ{US410gYefTsFVLokYZ%zxO$!|5Tto=v$7bnDIIWMBO_B!) z!?W|}_i0g{b#EY9`dFvFJRW{LU%+yaRlXMZ+gp9G)j^Tj!orpoG}S%>oi9H+tgKQb z8n&l`(i!CxtW+zaosS}yKh#9{GW|QrRr!xIA@emw(HNkEH$~$J0f9<26#SbK|1x`1 z|FqTY)LA|cPHd1w#Due{serK^TmAPeJu*jbY#`Jv7L;%JE4mVac;@V6g^V4x<97Vrg@Trx0uwH zl@4LDxKpmZx%O<0ChUC^(O5h6DNFFw{Sn`o48U$q92XhyiK*H~Iew6o9WhsK8*U3gn)NwsWcIEKvBD?Xfq5aj2Fkm+bLKnLB}XP+_&$pSWIw_CqW z@3Ggh&eXEb^vWt;t-lMfvx}Jy-CSMBf3i2nUHN&f?GGygG?h^HW5dDVi*d^9L3eYi zsV}np_I>`_ZZiUYa~gXHSKRSlx*Fyo6*z9M8#Va0 zy~=tGJk1)Q$F{~lB{zBSt^&A9{*XHe!^AGi-V`7uJiZMT>+1ZA0IxXIVCDTOS(!e; zcYXVf_8m%7$TIU`A;Rx5_EdQ^)L$l3wf5YpskNV*%IeG7Q(hN!F7mYU*G- zS53z44!j30rN$PHnNpS;a_paH-PCCvzNscn>}*#SnwxrT(rUgM!oV=p{+zWS@-5Z! za$}CMQM*h*r|5f8f{AnI0VF4`!<@ymyR%bMG#KZhr$s}es|UR>Ihxr0w`VGp@QGOd z{_~?S9I8Gu<+r`=K*a%|CQz?8Agz!ZD&LZ!$KN(5JB7?&H8z7+Cy(B`t29LmH)!89 zYQ;jtkE^NX4n$wzlr%2#K)IBFGiR#|2E!E63SPKu?BupKR3owh-?UD2iKd;OBaWcx zkYXyHhB~~UjAh$Ds>21oxd!t1;l5Y8`{zu&tE=La#wE4$5!x!<2t@?HECkc?%@gat zI@uo%2EK?h+sijdA?e;vY@RphUU|^?EQk8)7zCV9sw$o}je$fVS+Izmf|DuQOH(7C zU+5i>2k0^~^kDt{D|;&DxOn9&U7{*a_10L#zyLiMR!k?AD;DQVqfAQ^@5bVGoKRQ@ z2Fp#kUkweld@V|rm1O{%a)I&|TDT@L?Jyq- z9g~S)1Wbcnc~-pzoPx z#yPg{)?uHV+h>p~Z)6@6;|$r|^;@!J`pu`bEBAmGtjh|_iFLsG&kG&1$2?qbW@lUA z4MR}oj)Z1HlfWalHnyK8SlLK0nJ1ffHJIQYU!m(Ut&V$pVORD3?) z3E&Rur<~A_tSpxytVaJ~Bj9RfwE%_s7B2rK54YkRmsq2qT%&U6y&-YG#tqk;)up-o z2we=7V1Y{9m8#giM9J?+V!VWCWap2a=8>Ep*4VOSlH-^6YNmbLQEYYg)RVvSkizxa zded+$Jc)>*5;SyJ`~-jKLy^jb$#VTDvHFv{H#_QNpG;l9XnyRQ%7FS zN_r$k|Gr~)Z7=^xrH$JFqX=Gw5>WzQF3Zw4UIy?XAQ7Bg#qi$Ye>f5G^Kjzyv$;@W z2ai4-!-^n44T8H0>cOWT>f+P!3n^uRRoY*KkCZE}ZeIpcQ^kl(_&N&3jRuATNErzl|losQbe)7h86B$pYp}rQ=uONcb<<>(r~duVZzU$ z_I^c0^p@x_a?c?t;Emrgg&6)iF8yLMD?Q_UHm>4@GuOnP=w($z*edffMQ(+;pFW%s zvwchGJ0<0-Z?=J{fi66q0BKik9)5tVVgai0eX>LL6eJX?yrcWudm(YOpyWtg=2(xwpSD8L^)-xnLyt_h37c7yL|SiR zCZhWbLdyXzU~7f(tk_F*sO9-&b&T(n^^IU+5F-Xl3_YsUvyZgOOh-9)0b6mqM^6h2 zjj1wW<8iMiW2>pEOvFEb`}xqxzAh4=ZLQ#-ee8#Tyb4_!@T66J{zr)$P;V|nXoB)c zW4t(1g3(tZUq3puk=Hyd*fnNc(_7?uLj%UC5f%$JRyh1_W`yv^x@VUrr7vl!6GdP1Bac2Y^~o@=1u9h=|l;52U>Yw?E#%68=*Wc<~JFOKLkPTbj( zyc&fhKEv>8G2couw?8J0+XRZXNc!u1wrDMm;fsW>uAys%;){>xz^4!{%q#(w_oClK zi@l)V%m1S6u(1vzNSR(xm>S zPnN)(>?M-%#q=x0!QSn`Ly7wB%T$Plt*o0bb3Rt+^)e~yjc0z)vL*LwUbe7zb7{~@ z3t#6amJ8*;K!LPA00xGn?9n4t$oSM#ywYjX(vKcG&%|xuq6* zP&L4BmDKu2SbZ)zuDQA4#4EyR05~ycH8;~L}q$2N8h<+eS3Ike!YPnpaP_vDe?)JM-p1zn}YC&~rR}9Iyb7c_QDZqtX9nzIu1;3!(*D zg3gc>YNP%Et$}e;2Tj@^vNG zjS{G2!H5;dBd=fSFP?epNo=JH+fDG6e`2byGBz?-IGwC<5nM~pJ+V`>2F_6NR=kIt#=;b*dJ=y!Hc6$IzZM0QlijyZiJK)}v zJ;u}rA((0nRHmP5V8%8tO{N}#iTbNanEvRt{BWk%p^J&bqgInUnhDMB$VTQ&9pK)H z$_bPO$fa1SE-BDHK&`1*i<^cEZ!tFuhs~)~Djwy~M4{%&L6Du+9?Iu%4>OF+WwQqG zbzfw^6HpEqDBs|4K*~1&xltDAJ7#S;45PQ>AFb5unry)Im0JQpOM9Y)J*)%0&+zI%VD14vX7~y@ z%QP$f(t0LEKf%@9@1unr7c2MXEeDf-g4!u?DGZS@{z9krPSxA+(A z8|*S%c&hObO!_Eg#`tc~4@QY_oj9f04g#2JX-@ojqEn62K*BRMhtAVE10BeJhOz42 zq#>Ww$NFpitsJW3w(mx_Qr%VDzeh$^^v6jMpJZdHf!e^@{g)>|-i@1bK`3)T*FSA8 zE>R`FQ^b|H!v)#p3(9Q#Dcu3lUzi-b`jS(*M8cQeSOifuGp~B|O+e$N4jbwA=NuM! z-sv;N&$mwx9bmLgg#NSUH}-Qq`mk`unG+(PxDz|!e1<607n;x+!_mbT+AsrAf@^umuN;N= zlI~UO9-hOERaoss0Ix8+c`Iwcrr3Kryf?wc9~(_*d;Z8Q|xTFE}faPhd}R+CznTu~~~e5=p6B;eY3n34V>h zY2ukSZXD2cEZ6^0RRwlTvNXE+p3+)yAqKS2qELt3aT+3Nk(zQ9zn9zzuPTN~+4~Bo zS#irW>vD{x&ccF>XyWaah|u3vw0M0@lCLncsONb9sShRHt6TZ=sjF^1mS4I4#p3tG z?SY5*U%u#Q#EC?SEuD>>dkfCwYH0ZRY^(k0B=2?Dv!9hdg>Q9;e$wg3^YJ}`KPN%o z2iT!&Jbk$ItL*s1U1hn6?$n-FQh=+{3u~U_)d&2W^PJ1Lm6}J1tE)qIwU1B$uFeJK zS5m`Bl_@SDi)Mm_0%{Ua;&S$`2iL27@jmTk5<%uUJq!55+>x*H0^Lvz-Qmx-W15_V|2k157rDTqyXJRm{=c4$}Ie zi|z)wKbhOX5*T?&0n(|k{G{k9FIJ!0D(;SJIXroA4AG)DAEY%cd6Dk}(rw1a?l0 zhV?>uCVU)FdJ=qm88m2J-5r{M-3hCr;EI_3cxaWK^|IY!%_hK4&WFIHR2*L0b4W|k zn>irrs+EMyC{ozuwJ4rmdb(t*d+7LdOtKr{hP}#i74W&wvOZtVd+gtzQz+mMp3Cqv zday4!>hC&1?`JbtUmd8k>8))CPsB}abqO@3x1b|`@GBi^j9fA!Z`lYVbK0M8YVp|D zq3+1Y(LuB_=fsbIoqmqvd;D|(Oj25v{AXb7Z;W0IS^?&1P8TH~H&+=XC5Kv{b63YS?yi-|q)ZwjoImR1T8KH@ zu_@w5#@*KD7}HH%*fq3$m332&5#2exA6v?a_^O(6{qSB><8XK6Rg0{D7Kd ztF?wx@%RB|%nA`DjuQTMvY$!LW0Vdua9=KUQ zw-oa61IDL|Zfz;o=Sfql75H?33GKUAPpWQm;WL&OdtlNSW6i9`beyj)NkABF_Ip{1 zHt#8#3sxq@Kb8Mbfq8%5oxWR6|GIL$xM-DPuBA@mYQm*Qh!JbkUT9A>pu0NiRP0by z_H>)KYhvcz8T9e+O0G*D#hYi3gR$S8oU8=t`EtI<8AuQk#^EuQm1GkbYcS=KYc+ze z){)287?CP4?hnXnRy@IX@)L+9diBKKsITiQq-9Gr*Y9OHpR~OjPS7-2v8X@M>ArL2{xWL%;Ec$76p9`?fx-?0RGssf-ybKcSDVtzgYGo2$+3Z8t|7ps};+S*=U|*_&%f#zZfVBz9ADfa)A&S2tKROh><4 zaG=iFEnR$;(l==j=cJQ&GH(bc=pFOw#8?-qAS{TyrlM?&rv!r7_Fegl6aitZt(#rF zPMwS=+b%V3)50NX6hj)R&pkC?E(L&s~-kDUBF ze?Ga)9}Rc+X#Z(F$2NR%sNepnpu@eO`14eHQN?{=wNX_>Bn$n^`1?;%4}f>nas=+F zqI4fgO(I0CbQW;Za`=4S8-;ps^NYwWYgC1c+-f4B9%vGth`lNoBkbQM(&ZNmk79^duf;OpYS#m_2E?uzHRV zUCewjS|59xEH62@$V`e=ZEH(46$Z=u?Q^81pYvq&gJ{+Z)JPz{!jx1vDB+^LZw+do z19G{#s&r+nVgNoqI;1`T?IrJ`F55M&;RSTBru%1L7Z*=iJ>S~9Qt%i3nWCjz`mu#& zZ6KA@ZYu7KG^hYH(e5GaVB_$7=VRC4CJg>)y-8UW8>N6mnfZWSj_afx9w&=cozIi? z!cE7%SqnLwN-C-C)6&(ob+cZCn!j}uS2U{m%;EI9iC~6QV_J=QUASEx8;#YFb!()n zwG8ucS4QLE{mx!=l*xo(6^XxWskTNP9U&KL_TV?QZrUvOoNiJLX<4DTuaB(LgPDi| z7JsP!L(H%e=q^X zL9jDoT)0Y<)PB@Glp;V~*gdBBDLU6~^iq~J#23&!B8wBVT8+@SE}{VQl>J^`kM?{q zPzX=3-5lGiBz5jb*#1i4TSX=Zb&b zHqglh_RvbVarjK9`il4{cd^=il6RQ9xI`^ndA4qxL;k|s_E~(q6z4OV!C!8aI2qVi zN=$p)S^tNW?u9XqVu&WC!A%%1B?3L@)31sM*3&k{{A@pAblJ5K#8(oI(5+@qt?*5+i$Ar zM>5C<>wYK&4m@^mxq~G(*=LLi?G)OX%L#2?C7;)K`L^2J#L=&~Mba$?>pi+p7irdg z)9+eO8a6iBmQU|@6icvLb{;Vsa^l1*OGMB-HdiMdNRc{e)avHM8&f~O!CWfu2I|M< z6DlC3h8$%moWCSIPLQ4gCeLzm68BXyO?L8`fR?EXZCaTB_BLf617=uFD?t8im>BeoJa128*R8M*=(&tc%K_Gw=e1}CxS$b_HB%}rd4W|K+e zc;KWq^S9-9%iqm#n68#Q_q!SuH-^d8 z4hH9>c7NQSnSuRkhG0t4z2Jy`s}@l*ZP8)q=ol9xxqQHY;+qTc9G?qj!9rg`DrTm$sj+V2R5<{KU!;-AbeG$Di0C zTTaN!i1%&j5S$%gpTelQvcR-P=qDq6oM_PKVE#H7B3Cs;S!1gWRG4Wy6Xy z4UtE%*g^m5Bw0pu{}->4Me`Y{86-Ad#Fvb0w)fgN58H(r$<>(tqxKDnVYQyAm=B-! z|D2oIadV?Kw-_cxCued=l0I1SM2IpeMd}6@UnEJ;acgg$@}G5*{@^oziEu?1n3P*4 zB9WBtb_n)x&eV)`99enP2-0*hzBGx9I(O*iAU!P^g)t%+idh!5y$z= z=Fk6;anXuIT)b0R8V9>v&FReM&mAftVXo5tGeU@O3El+%W4rMGeXperZ;`#Iq^xzTqcB(5DMivoY|1%_gRXn+K{%2cfJTpz&xEs8v3&1sn5;q!EB3z)XX z@z<+rs}Jf>vAGFv8h!A->b%XjwRa)sw;qua?vz9wU=0QdQQMvT+75@;IQQc19#Ws2xMVhZ_6a)Mc?p6jVi`7=!h>sPH%RvMaCJb|ojDu= z0YK8u+U=@j@QI934kr3ipPsy%`>BrG8@!K{Da8{N$`84=_9Uv=IXt#z4B^SG`>&)D z5)Z3JYl+YPUOKt%hGaBE0gzPnm|@0VZW^+#Ru}G6gzirbfq_9 z7M{dl3b`psI(wP%_l_JtyYxxsy@dsm~%?lGMg!;7Hpj;>8fZt87?oMK5zIph zjg5ogg{0_jybz1us`rrgXRW5pOLsc?sTk<)aOV_bS(0ELLtpvYc(MmZ%RW`X3r4pA zPAaqf$*r_WyEVHp+qu3f{@%gWp>uUT{D#oHi7tUfl&XgN(GZ!J?FfyPX`=9xDYc1! zURPI`@SfXi5aXAlo^ebwhwGFxuEy-nR(3gaa<0@WU|ybA7-;{sVPm zgw$^u$N|Gc6CkeRBoP&evUg$hYfyZ)>j444*jB;YETruN2zX9vj4+0WnBTKp%YK*; zlE#TI`7{d5ElhEf`E#bRZ}GINz9KoVWX_Gn6XDxEu?UmOy1Hb)P`9)kUeUgyjjSoD zR&~1uQQOp^3mrY1KVt*|+>8jHq26t~=d|#S=Wx=8-53Cy(9fbTbn)9O;0%qALNG7h zBSk*=Jv(CHGVrzEK!q<9WPpYINGQD8CoKf!@Rg>zdJLrWU<$oYqX8kD#8l~@H}i1T z+E>lOjUm1t@@-M>Oqp}8w&{;RRLXUgtup#e%wjFVZ7d419IxUg`U6R~-lqph>*nCT z8B)Z@I^k1bI+2K2CL4{GikAATZg^~t7^s0c4NBMT9jDKb(BVRDcG~0#AL09SkGUBh z59>&Mrn+4^TP^gF9R_(9wM;4hsjp~5Q4x3_Bd&gd1gp}C zw`hCkdeZ6L_LrX?0M$e~4xRu!2DN|5xp^nUwoVHyOR`1Gd#qe|cUjfEm>=AOiHLQ5 zX=%4O4GJtQpDi7*S)8v|2=|)Vo#8Rx-#YckT@RouJE=>(a!tgLWaefcI})xOv*z^5 zZoK*{Ko7TS7G;UG40Urc&`dky1#h*20#Pa z!1J7bKS3_`s7frQ5pxoJOhZYAPnha{N!v-c^|RUz3pR^&mGrOQNplAqw(29Gr63a% z?D@mJxjLxZ4Z@u&a|NwlX=}rNPO!KR+H_?&I-UccOr4>?-p%$l;-fVl8d*^>S0CM| z{qu(ifWs8sI_5w`2Nj8(4hAsgx__ILts@lxp3R#ZZbm$zrrfD2$eb1CUuCa{|b2Q-3u3k9Yu&C++1VMDfMnOL;2@$nFb zS;i6pW;-=aB*saIyBi$wu-`FEGmCsW zs{Z#M?r{J<=vvje`t&@<+S)~MQlUwX;gkKlD@@4QrMo!VodHyoe@H3+4j=q^8%A=v z!l+C*a7LX--84KS=I3mLU{Orzm+BSHhpJjc4U`U19&&>yyCI%4%+QMppgjnqpR{U8 z0yPy5qUBNDvJ~TJdP_{p5ufp&4vw9(v7&#G50hmyp)Ut1TuULn8at~uhrixHl0W0e-ZY_e4K&uLVJ+k`ogmP^xy|qD}gic4C0hnB6+?)^@$q z08CScNDOv3ja7-pp^-KzC)Q{T$>XrqDHuUvS#YN`ioV2KaPdma`(KY-7>Kx`LHl{H ze;O|zsP)MYK=k&NA0C?FkgFGNFxqE_f^ia|=57Hhr zrdg(NY8KYiHE6rJ1LGlgax@RSOZTLa%lvUfy4rC<<9u&^rnIWY9hD|>7Cft?fkMsY0c4>|X*w;o z_wSI!lo~nJJ3VVdlaM4@({DYKinixayl-%kubxJNtZR9}MdSg-U#6>0o79UTf z1z>Uzz|}Zf!@dJ=ajwCNT3+;I9%TuzMfYhRBSJr^=kJnag6J9E=#e`zpnyNFt$qG& zkLcfRW$@Epy4pY%p)U2E3$uQ-1Fw zZ>v5dm;wDq-^UMxyY#B2@zWKwh)`cMpej~$v&|S0`UAboro)PAf4>k)AQ33()Yw1$ zR<~*$Ys|Q}-vzAEb9ydV4f#r7=15hxVOoj1tn=gBdeQjPjsmz}U8z!aGPp7mD+FT8 zo7|SIqKw3+tQ(f^ki4FO@ff38w^>hY4B$$$FkSmvn3R%IN6KCF?0XG$`Dp#Mt{Juu zL^Wgll+2U!RB^N>MwKd(i;;aHx41uu0>l(x`nD`o0684>>zNBIWze){a^hLwvQ~dU$H9rumE)}zDVWf%-QDl$WjqSz z9=!D7toOo?*4N6ulA()7q^-JEbYVcAVxTYW=ncuQ?GRXnol0cEq-lJ9A6=k-5XNoR z@V|>jg)Kx%ymb+d3X$#icsjb@bqj<5`&+GL*b|+3*)pYY2Ih@jCI(x8HP(#TxEaa1 zZa^e~Q}h=?mf(9fZO(7?4%~zd>3jjaP6BRXTT{~?gKZOf#g9v%tqyQZwVC{g0&tOm z!Z0yIL-jZ5fEjgmbuE!&ZjPO#>IKlF4-nDzN>3JglYblQurxjjGDn&KxCGD)AQJ{M zoM4~TMnExkvo(1^HL{=QS^h0i8lN$ISArYr((k;rmn=UGWkG5o(54}>| z^*LGX$e>r#I=HL6FO~L%6OD2|ePJPez?;vF(^7m?)eHpPBan|^=6s|#h#a>dfW9|k z547FHYi!If#g@A96nZ)(xa-7tauqc3<5|~^%iK^r{OO&V)apst4Ily$bpS`n=RV#{ z$kdX+4$kdNbJ9ko6roy5k0?eGlVn%D*?47p#~FKTYEqAw=mJqCA}A>wKyabYk3a$&5u-4eD zrP`GkInHcT>bIyTJLk7cW{Whxn8;T1h)ZHVp`1p_m%|jAPK!9Mhil_BkT&8 zAf}RV(?JtEM^jS;?_5@SPkE{j`;N|&G$qLBPMG*kQ(`pTC5`pZGCIogAS3t0+jYEqpC1aYgLPHf`y%F~Fky8;FrINs8Y@A1W85P)QW+Qj z_(Xrv$LSo)QxFk>H{2HG2eC}w(D=N32=1aQ4Az)$uGFaW=*vzl4M>(MkDh{@W_SBJ zd^-mnouKgvoIu(aZ^EP4-nhI5Uay%Lpb;?aE>~zwnky`zmyPvkmo2fh!(x1|O-H1q zGCA3-cYakFQq9Rpr1KE%Zk0P#&$Ns^`}iQd+L9;KBFP#-kH7S9r>GGbF(xrj-Q?_U zP4Bu;arZ~xCl9X0C|Kc`k;{D`3=hZwR?1K+sESS!z^lt--pY7-kdL%|Z3E>Q!5>At zQOXbT&rS^#_h!gIQsWIyGA)#Nmcj2;=_Je^NbO);9aZnqsDC%?g596`LNLX4DzwE( zy|azni*Lng0alBi=kSpCq(?oIC#c18I^ubdW8fVLs8mt-iV#CAaRJFt?3>@BY$zUET4hgYR-1|O&s zJAgqK@>dv_b5|E*HQCrO*A@bU*Z9Q{Se4Seglmr%6q^= zel5rsVs~a~$gNi0CK$L=v$)=NM20ou<4xF2!wk0m7#16WJN3eEz*Q!xy`@hUuTl zE2}NbX>+ni&%2AJ-K;qOoC}~0DqLTbagQ>F^_3<i_O`V(f>O!2wcnM`jQ?Yf-{|XJW8I6h24+F2 zOTg&Hu7R@@*$4r-k?TTdnYE~t{|s$0fejf{Vl=C4Ck1{Zs(DSZB%}cZKHW{`79)Gn zkzlLaW7^>+0}gG6N^biDcrLbBg|^6~=pN z25By*i?m(NSHmj3oUP^qgYF`=iLU^%#+uz#eJ8~*qn zf~N71>itq)1QHnZH<9%o0@gH6c+DOd7#eyq5Ol#%%V(gNQJRHJY{e-of=xaJsB1ls z)QLGyo-%bIz2yhRlS9iVF{9=i2j_5`6uB|SgA=L8p2H(PyLf1z1_kn~IThGf%Qroa zo<)w_C4H;IJd2RjQ7g`<`WSr^x?G6OnD#74+C3LW(j6ZnHOKbW2#hBQrhaoRe<~43 z6?1vhR%EG&iQBBC!{WJqMl9-$UnSGR#yfOE?T@TC*3zAJiblZabW;hZqX7ABm)y+E zLBV7YmlpF5dtyGblu#ZtId81A?)1%oW6b(!O@|1!*RKooD>fFqmzhLk`I$2Lm4Ha` zbKBg%_WZUAqlizWz5K1A*()1z554HAGl<}~drqqyvH9s0F+U&1NuQ^znSe`!8WSi5 z*wq*DbLKOI_C#|aHNTy&yacroCK;K}Kelu{HLd(i@C$nq|6Z&(fT8yt1?N_flNCcaHVCv-gUi87s+W*Zy3zO!KGd(GFbz= zA+Ri3&4k8;)Xv$Xoo{WL8iC|+s$3ngv~=^gO&f;lcm?jb5>$mm3>jesFVZ zQa({clcfwB4|P*G*6pfMFA-w5gO9Wc5b#-_9^W93<)^cJulzA(Qf!mvkZ;x9s0nZ6h5Jq_X&S~ zFFoD?R98L+rcaoEZ4!_vvl6ccUho+qg~$BQ)(5rfL>FZmGQ>mKyv4JOG3kz>pwicki1ypHuLYewCr|rIpoQlz|ED8y=HCM^lbw6VPzC% zj+BCiS{-`w&r-ggdV{3EdOq-^_Na=J%eRgw+EcF3ku>+CfLX%hkLhkv`Ab&|H0l!* zgG%?1K=yEcF_8QSB2-@vW|kOtU}64A5Bp!9ZWwkNM==bDXrOX%L&IZbCP~&M_-b`7Jl#5QP;PZAE#q9By)B}hI~Ozsrw_hgv!AMH`o1c@{_ zbzjKeDbGIZ&PGobCY&}bed&4HBg~L4f9V^ph_s?z<{;$nj$|ArY^?bf3I|fQ73;QA z@j;rZE9S-3>B>x5-vHfHT<2W|Y0jhPW+UqG5x)njFuJ{k%ZK7E(wGeZCj4eX&7CS! z--(r8>AUq5hX;OIW(8@^@8U|Ro1A`*ZV=;Y2KE3{;6_778pr>eNydD!#`B#=_dBZ? z$I}OtTVwi(6z|8(##CjE()tP`>>;~J5ELY6_n-dEUJ4Q)$-NsFP45`iQ=GBV)BpN= zY;`N{aLGg@1!)~%PQv+Q9F>Yt|Hbc7xb3b#lXz1w|Y{I`b2jC+-g=CWZ?cT9nsLP97z z{OY@jkm#d|6F*Q^UQ&rl4?kLDQft1#=XExyW9|?s-0a#8g!BBUqw==9XHZGX<9fN5 zo+e6xdGh{KUYV%DpviKdNf*t$d@aMgDFl>Q{El_n@*Kr%fH>fHZ12mI9D|Z0CvYPIdqbUjnjYpC;roR1t zc0IBBU@o~5#MT!P}x8;6YW*Nz@=eR~5r%^$VV&RrF zZBOCJw9k|#v@9Ltj*3_` z$M-C0J2M(X)@p!HJNII@{}yAbPcoem#%LoGbN0pY28}F4%19T z#_NtQt{VjksVSOlIyCCFi7{>kd7Wb_lhaP61ZOyDB&bmiMBQ!$td-HTy?Ror{iIRo zprp`8FtL^k^8up@y}rx2%D&iQuOQJw7w!2k>&g(A6RVla-tI$u3>MR-&Nj7ZL?$Q+ z2vH9Z8-97^D(rkZDLEk8c~IA9VvQnv^e9gq&2&{+ieXsaB0u#>rScVBGq>AQ!6$}d zZ)z>0jjziokcsua`4PU!t+R1R=-0S>p^N;?)_JSvb0%EKQ*J25_mEN55V~k#H>Yj# z5>YU6=HfJHGJ0HY@S-oY3&V>V!VPwc0l17?SP8}SAfDuI=QqlAv#+$41hBqtu3)V? zS(Y)6_2%%3lX#(eTCdl{4o3As{g4|trRCxNC?}J0MA=8s+A`m4HhM8tR996MING+m_ivn)>tOi;3VY~bVOXDF8eU%A z!wI`(!GPWixe&k&Z5+$V7`l4b;Fok=;Z zn7kzXVfo(To=RMqu|~jaJppI;+Z68>Okxj<66A34Ve#?l!`DRg9Qh~YjJ9xiG0fh@Vr&-)?6 zj~L;Bw)gM&A%F9wd!myM)AoILYRMN2Rfhw37Io{g)^0#M>(lD!-jip)DzjKRPt`lc z70>?71_~vC{!Bk0e8bwu{Cr%&6J{eX_6mp|MLuNLmc~(@m^Nm-(&HpHks?!CQ3D)Q z=vX0tS+5myg1T}Z^SsrV#HNFSMin4sNj0li2IiVreh4&`1V?G0S)?fU3^50relaW9 z(&Xrx|I)o=*^+u%%%9u|rfl!z0`^|)J|IaWC|S**1e8_5;_8%T z64QUr=H)4OSB+*`Y95`Kg7JmzmHBvs@*PNtR}+=8mo6t%gaT_zHCAP8oyR`}Q&6N{ z?KJpttK9HQ{`UP2ho6VNe(hF3{;^z_P5l(K>H4aa{kI0pue_^3f;|b#n)BNaCriN* zWQghU+3JuB$!{3x#R5|tvYn1Dx##RyW){^iP>VD-eVSlT>J5zHx~_G&pmM^L zO&`Dt!rIV5rTZucML@db3TXA%vtzUMZ-Yjy&OAI6Xm`FIx!Rf7;E4fRZQt2>l{p0q z6jLyP&fm_~R^|)}q1YI>0l{hu2l;NqJ@AMl`b9E%)|ZXt=EpkKpUZ62# zuJ-;W0zLFCVxF5D`v_DqfWOW;J2T8?J}4d8WR!eg_f&1!fe3|BYk1UG6C_^U79#bG ztE~$<)(Tg{iZ5!4#66wbK_~^RBnYF+-%UzcNjUGBi;e&R0w5c&yVr%9xg->GUHjl|ytK>g}|ONN0e4(+lNJ&Fs36G|qCb5zL%I50!PC%6b&Yyc$U!V#Fvw z0GobpZ2$5O-IJtgM!>>U9Kj7>F_aK@qe_GPj&#RPa&pX!E~8d7GAxh}gCTCr(6psP zTlz_a94a+D8K5!2CB=z`^jor5rQfcL=7H{mPZj%iAn`vp2Aqk?Ps|`>-aZqOxcp)U z`Egezq4%Sk?bCJfD(j~wl(d0rKVi+_kc~dQp+{5nU|p28{%pm!Uv$2SM+ed@R`i$| z!T5GL9^Oj(V^Brs+dphBfP-XFFMPnpUJtcCDC>Q6cl$~j-S*XXayq7v{eEZQuF7UH2v6Ob$yVkH4hXtATD z;P&AXU~rveOgmT4t@(5&YKbrqa@Ty$2rpM~t_x6uh(|0bM5Pbnh;0ND@6FhhY8+a` zw{#7EMdZgyMbzmEBnqNpzZU0h=2RY~GKuMPjhLCXhE!n6yu~TatR)oO+7b3BvQmfQ zqP6KWk{Bd1c)Pg^TxYbuJ*%8y0eo?d%Ha!fO(hw1hO8N$C(pP$0e=KmZzxcsiAo<` z#Eb@1V){>*^YTZx0@<$^;Ds2@cRtc%Chj>n&zP0#hqR?>yXn&Tro_CL%WdcJziVBq z7X?|0c-;(RDHubbM?bT$lw((n$^8~T6q2IE_;B4W>WJr0T@wpc_*8u<1(zR&<6j?mC4;ruOwk2g@T9+qp@Q|Q5*_P8!DVX~~BqbP9tA?`EVg_}e$ z>hS=JElo9NnF4qqh_AVGu@>PIiH z?5wzhQ)ybFq)xJJW5syCXFMLC_|U3%9gy~j2nX4gCwYpsFToRXV5Ut9Dg;1Q2xuw6 z`ARgPM$Ht)-rcNNF4uLeuY?SPwHP{yJj(@mmDx>UEgU;-)2H4oZadYkZgxUay`R@p zXW00){+4S{!K39w>R#!=N_V$4H2{@D$mtjy*g_ufb#Nu7m64^vQafK$oTlFn4%B7_ z1sM#nU+eS0W1XRS(y#N98>DkviQTe=-3PkDna{$1q^J-DWt_vs!a}#cOJXo7pq^v- z%#IPHS`6LJo`4@_*fO02NK4Sx{Mp0vpJDci9X?@}-@K0iMdVi@-K_WUHIi?h$MG8< zi88()sMA0N&Y759Vs8OBUR@53Xnwdu#rpoJNouMg=&DsSY?YHCY*7fvAGxjKQIGRNEXYZo1#z}5IPm`a{I|nq78gxjf4vojYzRzTC zYQ$%Wpie6|8W>1vUMkb*{cBYf?74oonnus>_Fo>~*RI;Yc)9fI0xNdy*_M&nUn* z2zBy69zirfXMq!?-6>4C&CjVTEXOOJIiKJ7+0QZdP6@`d-}?+I!!T{lL{4fBTGX1f ztPa?~e4r1guFV)vKcYZL8QNBHjB>LX+eL=u@wRZ0pMHF6JU*FWAZPjrexeD5*?-;X}I(Ksh?Git+iZ^EQ=)p#(WAX_?2`Jd{ph5q2~CIclk z>Uk4z#p@?48$bZpwQAO9qz8q5q88*y^g0o&(IjRLdQlSC`!NJ%0(4RKW-RumS1u9ze z3!yK4-t_hLHs0?$hdiSxxZlcedzFo{ieD|lcgMN|WsjCcg7 zkDBxabO6at8;!cCCQk^EI)PY=s25aBYT9`a+X9wqva)jd#|I>mC}{%=hEF9l`sL$+ zU0YiSCCjo<0aUfW(lhA@W5B}!b`(@CTF}Vk6!ePmMBz`y$Eux4Fz1G+K0j&I zXa9*3gX5`6uxs~J7<;m!dUZ&Pp$$)6RA{eWXUOO+;5)&l0v)@0%aN~`v}j8x_Ff;LdY;zu}wA0N+@;5OHT0D%oKU42z3P?4Wc8Gr&s7c2E<1t>IHOjV>uBa5IVDGSJ44sE(B!zFi+-wGvM!U$MRM$FM$4` zhBLP80i+0|@Ua!Df0u%RZg?ig;;*y2qB(iVy4D>n75MiLak3tc7eg!GqnrGzP9Dxd z`%7n>g59+}0q16oZ=V++7bw$ED5mFruLl9tMa3lAq}0Cx>HvR))Hr?6n*;Mckl?|D zZC;#&IuHp?WI2tx0THu(?TN9#Hb~0Oi}&^vZmbdczMX9MpKdJdU;mO8U!uz+F$DF+aAVRUOU6M0wf;`j4=u52GHPte)?Ah68utb{h12$t-XZ@;0-tv zD7}C+NG8(aO+%^2_G07TQ@>G?5Ic2`32~l?v@^!cHxU0Bl#2VSUkv`f*k6AGIrv|~ z=|6A%zt{h7JpR}M;`{&q_OPKuN>g_-p=sAe>Kx?`jsmxBhePu#ypGlf$=4hH_i5o& zR5SMRo$pq~NF8xv7}eAHL%UX|N)7LSb=`V){gcJ(4P7K*`C#MC=`1*!KTPlPc(Y~; zp5M7+f2zEm>`W$bxk3Hyu#>pnnp{|+ojQ!q#~L&fu3x;_vzf*E&j>JZX1_`Qw*J)( zlNg+#4TOH*-XFn!MyswIrTpeAs$ENfSGi3a$6{Q}bdh_ZLqJDU_~3a3&;GZggProh zy$Oopa51ug&``|l75t_t>#Ns)RwDSG6(Utv%eCnKY65rC6t--ucttdb7X&ei+;`Qv} zL@8Uk<#cxz3G&jUuUXwR`BVH$)7JenYO*lHpFPp%H~WD79spV;O-;?i_9t>?VUys% zHqbpI5xN`MZ_*}$ul?~88h(0dDR!DX>VD%Da}2X~AStg?utj3INq;N(?ZJI5yAIqb z=S2^@o@Y6!X=zCF^YiD2VP3&!i(cfAA#nO&FG~|ix#^(av{mPQa(C}6saS*gI!C|V z>0KxHtJ%fQ)=y~5hb|?CU6_0a&3FCm?JhTSh!wY)biNS;;j>JTk$W5ugRZC%py1&x#x^V=p zze&8V4?)92m!p2JQlsw2$3SVnUk|4Hg<&W9oZEf__wVbYWl%=fZ$n{JFS|$XdA=M+ z{`_BYI@l63Tyq~N=(DnrA*`&dsr+2F3w(&tWobBmTxGk6nKI}v0?x{^8)=$%8b#jJq1I#S5T$n*^I}4O%;3H zY#+Grfi5z?Kg-j9d}Y6u+So`2S#qkko3*ZPS%xjO+$RTpN}HeMB>8|5T=79;Zte^r zuH4n~x;Z&-#c%ZPB6CAe81-I>d&`LM&L1tn#jo^9=5GgYK`$mv zmy>Z->=4lx;PFAHo^j9<9o)7q#yH7Sz11~;v`<{ zi}z*my66RC7Q~r|0{QxWjSw+s-$5199$}E4XZ4HApQ%He7k}S6LqK=;&IAri2qawm zUIY!a0pTgSf>;_qgSG5DV-L*KS*B_p809GF3I_5h$U)EZVzc68*R4D=3yapDvAUtS z&s873rN#?FB0%??hqYnE@j4ed4M3WW*p~f>ZJK+cpCdsgwd^qv&MfKuTD;%39=Sw~5iC_KNu71a z?Fn-vDm|FTbBN#3UGyTklhavS%P==Lw^F-RQ4-gUG$<+Bf7YAncugOT>p{!?!vWs? z=JliHIr~8oO-$hnUtC>Ix$-AhC z*_!uq%puxkHH7N`#G2qOTPeF*E$+?C$b%n|a#W84qTC0QUfCmT*|0O15SY5(q_6_} zJ*=zCOZ4KdRXaqBXATa+ujB*-tk>GNwx)e?3jND}HkN@W^e_pL`m)i6B{()x&~P-s z3;fyrgMM4j(Jv@Fi0IZA|I!XTgZ|Cs$>$CO*2MmrUC>ztwKZ;U5lm?*;y?$xcI-HQ zH%?j5)P@nbNL6h|=r|J24P%_*!@I(ez)h~LiXr*FO>+6X@lk(wo5pLl0gF?HF7?{%|Xv{5@H&dP)vBd{2{K2OPJM-~>1p4srx<9yl+&f2vZX6>zdWNlk#Ra`V27@h&~7@DSR) zJ1(Hvo?So3B;vPy7R}(^tr&&hUN3`*NUg1{rDq)(8Syb=4|L%xYQTadBqn0)_X-Xa z3uUS@oU>0z(>)pBUPb?QJR&t!X{p+MM&dkY_r;hnR@;g2tJm%O0?lg7?a5*mLi9T* zgP(MSgzom4*bf)L>*{!>cAeVn`}_Mv_Wy$t(%*m@>K&BPLG?nFB*zJLzjd@ph(?8( z!gxxwTsP-jov2Oq>_H7+B1^%>0giyR!IZFV(8~yfp(2aLi2`+BKR=YdWL{Ob0ie}8 z-I-P^HwihN+ZxYDg6Lk3#8#}+NVEd1;(vSV=?)%TWHz+Jz|6{u#E#s;kr4vxd(oAe zm-hff#@}D|6`;7&RuhlfEr8#_79&ykb#tH0g@T8Nr=km>9TEt^02XUp_FkM}=rGw} z+>5(PIkHTyU}F%)I@o_gV3z`&2BMr~C!)Q^D;p5q41x7o|Vouf;DWhDWR1{%Dh`nMsR?Xrg# z2+ju}Zmj}y73L%B8WtlDsjA`>;^Aw^Q_5KZH})Z&-sMNQn5YNY?d4>X!Qo0r&F$qj z=&yrC5iY(orX&YCmDd0(uRLowTX0h=H9!Utn*`i~`}JX$T8SRg>9i?12)UU?td;;0 zAl?}exUvE+1sM)E002c~`*dte4YFjZdzHZX(u}^i;gkrEN#o<<^%|F2l@+1806F3% zSh+iulR6^%SpID_KVvix0LW_w`vn2$>a26zJ^)^XU;#g@3xp@t)WSlHVKW?4(EBTW z*lfJ(Kd$sxy;9Rh7=m!3`rQ$<2wQ~#hqttqU)&k3suS>Xknc%4y}>vfTC?)I+<9@? z8hS?FC8#<|6vMbbHT_x*smBiwwVsJdp2E1oioX7}R9aKaolkxW^chw?^ls7Gr&g&2 zZ>RN5uy_}$PAB%Udf_y)0c<-o8#k_B`Q&J{=fMn!m8D!Ao+Z5;9E+3_P(Nt2A9y8( zn0lAJ>_nT0v8Gc5hJ3Z5hOY(tFu;GNmFzTQUN*an@Rn$A)BVXk)p+-6GMwfeRV79M zvT|H_zrG2=nohr|HvJ>ijm${dkBcm(j1Tvo7!FoB>hG5lk(k zeSP(!rzXIfL%^cb5VtNB%RJ6hJ(s?=wnizG4jd7@yj%&u^r#)DdIUVv_PqG%TeZEn zH-W)5m#QSk4+W0p@^ZV_5zJL?b+vP4GU&DW_tzp_=a244F|R`+_iY6JNMP0X$(BnI z$ki}6ZU8HMV%^q$hvyIDjV$o{O3!>+gyh0&kG17Ba9>9-&Z}4qJ{r`Vf^_E>Yo>> z=-7|{y+Rb_TdF@l@MaSRwUmFp^%Sqh8SqO$9)MNfzeOTA$*hrimLxl`q?EB`q{zjh#cj1%upDjiift2=Mtr@0AUwD&MhS+(^ z=b0*|jrxsLt~|ql-qyKQ|HfjDTSBqjXe1UFcdH`Uy?WpsH~aJb!*(_4L{TbNN1V3m z;YV!ojgIRToN5M@77{vo*lh6?v5Uo6NwOX~3Z*ETRj!2rhf2(Uo?maCi{V)af8?;r za>;^S?Qu*mtvy|awNTnHcLmL6Q36r&;uw26R9MlAz|m7FQb%1%C?vs15t=uaVAJ{e zg(+JCPEg^dNiY!@zieTHvCuPXazV=xGhL7s{q^-lG%vy|AEtIHEoxgge_59P7Ch!6 z1`1ks(yRc^$mZ@YkMXq&>Fji#OoqZIX}X)PpCi5kDT>pMjzwDE)Qaope&u43EBFxXCo8bg*!mDPMIPXZsjM1*@@ROrsu zi0E*xRebQ;td(m?oyOvi%9Easpp)#`Ld+l$x2v0kM`8Kn7UK=$N zv8oYEFq)>-m75iIs}Am_Ih?_wX&FM`=_CpKz81kp1REA224=r@Z-ibP zofJ&wUzI5(TR#fgT%nahT#VVwB4Ui(;>M%p&du!Z#G-{ItwcTY9mGi6Q>DoUiTrlt zIVl;;5q7#J2b0rjq`3bonWrvaiu_e_U3R0VeA5mI`c}oM)4Zy7VGbOwQs?DQEZC?F z3blI^Ys)2bn-yR^G_1bcY}wk<(#)##x@M<+TnDyd-IV6#rS`1n@be=U@Pj9G!BeIF zbup^!>``;oy;qo;qyHM`E*Asku(R)*leqrN^z^rc68pT#GqeQC#td2B(@C=fG@Dq3 zg{~c|!#CsO2{>Wf=L53iPaoISrBPPT545DdrHiyFE`-jpR@myMFk6`qlP4sw+s38R zFXDxN_MDu7V(SBgXCn3 zYK@bCwlvG_ZAWprFwRh6hwiu+TV-lSQuq@LwIk-*jV`4TerY)kIwCT3yF{oPa#ArH zt=V)Q;m%5~iSpu1D)~hgW?!C1kJxEz^VAp0JPjc)K46gTt#+eGt@mjT|N?UqUq#@n)^+>gzPB$Hq5@_H0Op$Ft-}FqulGC3*H) z7#P?&9(R+PQpnFy;6*Y;D$KF6zamo=;VHx_U$=X0TZ{?jf7_-S-1=CQ_(qq9M*;yb zx)+A;t}H^kIG*8+QVXOA5K{@yJtG*)+ue25!@Zl1)UhfSF<@ai8YldgMO6h3_Uc@M zIXS$FW(}SrdcECqt{&-E7n60o`>U}{G{g3vpj&Vy%184qb99D*WFm+x3`GUm^=Uox zaMd!b#g%eRS`=Zz?o}hH{T5Kkk!fpdS1P<-Roi}(7USfWQRWMm|X z{9OA?_;sLJf)!O3T6{&tDt4q#OnC~+yF!%MXLW%m@8uWNWytclx;*|onB#TsUh8?O zBozea?Zl@mWBv|Oh!l;3lYKd!>Ddvu%>irc@xl6f)!q;@@F^u;9u$7T-sSJ}Ke$ZU zTx-h|<<^j?z(eaCxHz~~y%pQiC0;uyD)PA@hyauH?&ctAGQY4@AH?W9_30Zy1!xvO z_zhuQmCYv4H@^uw@Hh&q#%gU=fYIO#5IzQrpr0CBCP4HgzAY^;{4+3mIZ{lp0!XJu zGh+G3<`X8@Iye(Orx%L)slv6O>e_61_KAu1<7OAz)*f7%Z;s_1E5E8?AceELTyu|@ z$G;?;*VInVM@dpw&zmt)BsR*q(v0mKOi*@blG@ZXzgX0t^?ysfQ2kFpned<{du;s# zvp1oe-iqz#1ZA1Hq}0p{0+Nbc_rw0)Gi_#C3?X8%jM^_|Dk37x6g^&xjUFDo)?vKK z6E!YcT;t=oe7|!Cb0!&{<8s8a_bw0ZCnR3YCUoaA$B$))eCTe#!8L=0D@U-H449 z;DnAsds2L;U}eG1W7hM^YdEAgIMcg3Gr@@MY)+nw%Z&*SSU(g;=0c@_*{!W&R?%ha zL7Tj071j4I#~FSy zbD}h3Rs@)SUT>A%XwS(}Mn1zI0-ha!jm@2}-> z5N98+@yC)AVB|{C;Jgg>3G`1x-|0#$G$GIfL7y{RzP=-U|LTS(H%H8QY+}nJ=cP0C z*#t70B;S$eJYEnyD3n?Pnir+$N9m=b`O>#a!z1vl!OM34-JmVSw_iBgSgJgQU>yO= zJBjbVoLZ(=V=I)`+YO_&i(Jsd^4Bvuf5~m zYpwm3=Xu_DuWkP$v{@vP|I;qCD7a)%PGZu~@7vOlE(8KnU zi{wsM+KKwxzb!_}dP_3id2gCO@M7wK@~S;x_drx-PZFwHppiRZn*WM({0@r-?MXG` z`oeY>{jk=YjPR&>vwI~1Od|Ra{`{UB=X+3CqOq;5@B^0k$KWk&0piRzj$R4x`hrI{ zPGl#k3<7wtXcrP8mYuMfW{nmQqQ?~~*4GosBl@eLf@gtjWt zwEpC=6XeO;mlK5I2S&Nx6SiG^e^<8WLRY-mFc_|OcYeHn8;BK-E&VOB7Q)}k?LH4Pb^JJL$8$XrwXE6W@iH%=1^*HbkR zznd^z7U1Nxg|89Mz>E|eeloqn8tvTo-~mXW7{H^mdkSFSC20#Nl4*_?0gP!$I!Y)q6 zDZ*w#sxr;YGVE*Nu}BZH2XR^ajJklHYQruM&AHb*M^6DyH)MSnUfJB zKc!V|-sJ$ylHcvJXsU&Cu`*6RW$V=%Q2bkMt4|XxMxLtvHg_BMv%Sx+sBD-Rbyj4oy{_YO&Sct zwzqz*jpI>uf`EDT+EDQ|U`2WwDejzdGhB7Rj5ICo$AzBQArSRoDkE%A|kx2_|$!f{s;<(Q% z6GC8OfFSvgEyZ&JcG?;H#JRaimIa4AU6+$BkLt`jyShBk7W#}3P^Gb#tzyVDTaLd=^;SwxU1hd>n3oTJc-tE| zfqyq)q{qukvLaWp{;v>!KSqTdojxhM%XnCGa?{m*AitoJypnL5DedaE0d%ZDhW_NO1VhE4 z!N@$Ind?xsFlw=-AWi22;K9{ayxT$zO!%D5J~;~lfl#-*m^SLE`)?k#%n-{N>aoJS zR=-(ib4mZfYv(@_(oPafMa-|+S`W49?H`@-YW~n-tQA2!ut&8O_2%0Si;4jD4+v_K zc_hebJiri?D4R{4JGXwbw^Zr1izNq+!fz!|r!zMe)oOc8%i*47^WD`ts}r6z6yKp_>XFu{8T z0y5V7JbeVDR39Y0rA1m(rWM9y>Aac&c)sc;mW?d;XAQVW~#5vm0=d5%rco*WF2CCB_s1 zKz1z6wVHukS}t5BUu}J>i2iBC@E`X_)l#i}aZ z^|1fSbw?i58sZDnq!6VVfE3iWYeFE)(>UDZ+|9Z=K=LhC*XxCo%Um2fWW+gPGi##& z{DIsI;fV*igPO*8;P7gP51+fzJAa)DY8HR@NbELKyhNf7h~Uv|XJv4k#iK65V?iN? z>dKy8VC{ft2)H@Wms~GJcBOVYRP+=W0R2yA;I2EkKU^d=-1Wdveb!Q|ir@`^Qzani z_R|fM_`~13TMSuHt@`%%xxJ7oU7F6;y-|0BBH^5dhw@?vhl} z`nL1#O28je;B7VLxG<${f&lc^D`;%A(7wv*1t7?zO@+6>8nDjJ9?`59&Ky+c zD(dD8yn;P>+b(vUeU$V{9&~t@w!`5x=FrESo}jfr{=|DuG42aa>YUYzym>&NAv&6m z&8ioow5Oi~v?p0!sJ#9C@Mj3|>FrYH2*)`GJ&94wLV=D{S(PrNSno?k!HHw3Os9X1 zOcN2vG$<8)8 zq+8z8R-7On^$2AySP-CE;sA`Gw(H|dSaZ$$u8)4+_3Ycb^Bv~+{RiDioxi>&F_jv4 zY5<`T{2PgAt=6>2>V-NDUafD;+fMwNrt0`eP0fwJ;i&b6GeApG}D)sUTUwES*nm1F8a>O=R>cu(5t6+dfa$dm%H8$4y ze*b{+&-^z!yx6#dTR07{L0juw_kMD)ZezQP@M*4+l=lVp0*Yr>mScsW*m`FtcShOJ zJPS0M`LBm8DlZ#ouEpmm*43kI@1+O0{osZLJs1Z1GT3!lZ`-a(B5|;`wA2agb+~a* z&3mm2t$V8o43l9-MPODL2tdtD`xVlVDXu9jhT2YY=8cUk9rS zWUK)B2MKrSfbWGbo=~{>^LfVfwE6EQ;79;kC$e^*k5`!)Bb;D#0N4ZQqq#p|r?$44 zOtaqSi6bDC22$;i<3T~G5OeyULQJ{nqsF*j9~$_^Lp`>~Do)^;8jX#LGhWGS^gMZ2 z18}O3sW9RE>G_)7@3Xu`I^$yJ`tusJW^JRDNe}~2Sa)=hF55IQfh4MS#&{a~pk+NJ zJ=W42#N z4hASl)2|U%W9a$y^%>xbo>MtWwWPlTdN_YjsBXME(_d0RP9Gh`EL#_XHn2xhr$Qu~ zs_rlwUY)|^M`!e!a_P#(?+N&>mJ|!;e{C{EM=aO67*S|GI%1c3Fica zjS=-n%HCcJ{Nn)qFR0|tOQhL$+H7q$ocoOkr!gBDD~jpljNdDKeAQtMWGg@jyCO+! zo4uR(74oa`2TsYBT7|YPMIg7ODc$gIUoc+S{8KhdDD<+kiZ^j`_Q(zeBY`FhCvvg+ zf1^`G&uiS_A)Zn>E-A<~Wo(j`cGd+%W#x^L&6IP|-5K=IjnV$y{$)YKkis32G6B4 zvavcZ0D-{78?4trYeaiv?us`%@vZsBZPj5*sr+R<)a2BeKklnjLUg`B?@(n-1TtDw zhyINPO%>8JFVsZ%2!SQur|JNf?A%XJ46dK5WhJ&Wir8JAsO9Z=&J1aPufche%>;)G zQe}kT8s^;j97Gq673;JlEjael-T{(iqr^k6PN1Wn?MAq+&jYlg6|T>$E|n(sV{@7K76VbOtKpt1|OOn9~F&z#jPvsLJdule_;W)ESBjfowWcvY_GCDJ@{LA;JfTQ6 zUoY0+pb{hjwxo)nXil-S7i+4|p1(?8A~M%o6a0x(RQ56;8m3dYtNt z#?QE+&56G5_?rtB1|Hn;X*b;N?PBGAZZ41Y_L}zF_SsZ>-76`rO0)o&jsl7xe$mQi z2rE^Yf2a0f*SK(1H0@5xt8>b6{C|s}`I~U>5`oKhbkE8j{?=-K^+lO-UVqfnDqp{M zo+H4P?4a}=J)SUfxOmr8jwwFdHj896HC3yM04(kl9Kof+4n$f8d{FbQ~ z&#(;-<({gtdNpkEmS9D=Q5krM;7L9;s=dQdiXC1W^=sNJ&7CXg2NRqCnNTs4fU9Ep35l)p6=MtVGvD%czaGm;Ez7j(Bbex)?^|z_ zg>k~tWy0t;3YZh9*om>rWX&PB;ea`Uv6L2bDcafGoT#CABYV#ZIMB-n!U-#4Y1E>i z0lxrI)ixhgJH5}IBt}F-TAeC+3K#M#StdP zp1wZl8iqI3j+{AEH>tBx`7$BbXjcVrZUSdYI6tiB%o`vN**vD+jbY!6YY^v8pmQrEHNy;J~U^UwxV3;9mCGX$!@raQNR{ud4OKO0CQ=dw|>a=_j}?eeJi znUg30)MTrSs3^FHbPCoq*~Ec>4^}^T07{IYeijT$rV42qx8qfM)_u$TRpQK%C08qN z*nOrt$l0*OfgfHWnH&C?WSskUexR+|Gh^Yujs(8N%vmKrj^ zjmOcO5py3vsw~Rd_7q6V0Qc)P1nPnqcEhQSeGR~7#_LPFOEX+L+n-83t|nzs)<-JG3Q*ITXI zZn6CdO-Br!ONm20e~jXYCGqBel*s=}vl~l1k7jSl@}oaDI%4Umt@0Ohi?kh=FQj%v44)e5PJ~-UdShiKE1B3sMzwD z0D8fxf1j^>mXSNM;|+A`qPjZ&Ja77cj$6|I$mBQ@SYOWlKeM^fXvFilzS7kBb~t56 zH^IRKj${}&=&~8{j1LuaYqOXi_ZyY(D?Ap|y4VA0m+|4<$wEJBwi4(-5xrA=F_C+V zmP=1S54|!od zH)w@^HQ1CQLCLR4qZn@l;G(6BAaeT^O_`KjM#&Lwkw&f-I{{x|HX6Pf%QDH;dfk`7 zeOI^G+qrciVHNsm-Vl66}uNYUI|`LOg+TwUfNp= zKH0d6U3(ywK@8}T-LnL9Fq=JKzLiqF?0l%*y%4O^cSkd6U!0uRc_o0tRVfxdBoRhH z+88XsQPy1Qb*tj7$u&irnOGD>CR*5s+q<>~3Y1X`?HK`)vlBCq`Wm>a>MMU;W8tcoWTfotqGtSapVBUgAO-Z(5_iuY9brUl z)Svs?6rIz&Bsn)$n$V)f2_^KA?k)|*H?o3jy2w{Q8QpV66Wk?s^^^5JTlltoztw4M zfrN7qkY|FM9&J83eDgIcsKGe8wx&o5#|)R-et2TqUy`_z#=S^#j*$7^S5`P@}RNtBui2lo{m6yU$)q7`Er>f=y0BwL8-J z`$1xv+s>H%E&J4fujj)9iLFI7T%b!SsrxV?z{S08*mnZS=<88%4BC_#AS>y!T~y-? z;jFJ84#Gw%HYL&gby8;38&9uF^dx~nrO253d5@b}_uez%4pYn^O&OKL);2W-_B+FB z+quJZdwZI4QL)k+;8BMv^02jlOO3e>>aE`-=y&4juE4gRcFFh(Hi2D)El=0=2Gt$msqKf@>YYFI4AW|;hlKI zW|I^oU@UfHwPuti?4ez<39AfQM5yUE2^WAm6sgMHv}1rf8JZ0abi^bcZxoR&q+f;U zHE|FQOX=PTLS!*)2ed@#gHOg~pYD+Y`-U`mcUG}iNQK=CE0rKA@eV+u~uxCof)Y+ z`9?O-f|7EO6+gD`hYS3%dcIM(Mdi(_6QkWL>+tnz3xN%yfq)(x*aq{&ae-m)Lxc!D z{Ms-79of?0zFhb;>9hQG?VWgrQ-o%G?5W|vRVDaD0EZFl6*4lvGw(UGnQ#?ab+hlD z{A`~rpRZ=mR?F}+95P{Lb#M@mjYyKz6N=4E&j zGN=+AH-1ETf4M!p$D$P{Q=+YSPzHgccJ1fT7zc_lDkp0#yXl{neS}*E{kw+xHLm<2 zod3%8`x01%OveC+<;c;JjRcQYiK$15f$Iuf_4PKz&X2yz%P%<5Q@KZKDdx zB^K`ZV~0VoerJk=xvuB-Xyph7SuFIu9Vc{eNq zx%-6c)k3ug`*MMo62l&P5ml>&AVkWLZB}(A)4}YD{L$E94#DwJ>;ufHt0kF*>=2jP zR}1Vn6G|;B%f9wqj_>Aj)^Js6H_2Gcdj7SDO<=l4?_G^P*911-)A(J!0LSME9u55* zl+v<;r&!H7L^rNs#LzEyclRXKn(3fmnWXCp`Tgx2{Nv--55F5=cDk$P$9&-+J{gy1 zctn)y1+S>MyWCPLw$!372ALqyS+TCC(Ety2%C2BG%}G^c7@fQrHT>aDZ>)27sTc^l zcY$Foq}SCpr8a4P(Q}~Zq3DRo$*5bf?HmWOV?4DDKAUi7e$vx}g(*UTBlzQX^OV-c z9z$sB4Uc@~fqlMQIz?*K#!Qnt#Dj7!5Y#Z6!`3+e`IoO)qgyg8Bq!zQW35odl(CDD z-9|H;QmB^87ikdFi-48$qoavyfsY$S+roV(WtFG#4xerc;yZptLvbn$Ye`97b_E4yCL)&mLwJf5sjrbtb)zN&q2xX-?)7FI zBS{>K*zTB{Hw;$Hzj6J9T~k2~7OE$CDUQ>xfKUdW6#y7}*;k%Onw6EJ6Rj%vf3F1> z6DOL1<)01cZEZ_`9g=rC7~CCi^J(9^g*o*xbal5 zuCyjdGsW9W%Ffe{?WLw~(ycm-M`IADK>}ACZ;Kp_>lVIx=^XvrK#HT=q!5Cw)!I`@ zh*W1MkWLq&-W*ZniMYu`Y zmVz&*Z@TMR3qu3v!B$8!Leq^T?H>8C4q3OHoXw>okQ)u)$cBKm+=-B2VeHuRF3$My z-jvd9DL4I{IR=`Vs^MYyvJZ0yYBx#sL)d@fR?5m?Yv@zp5=!G)N*ysy{xT2?v z)NhpS%{|edifvbk6A%2^I_a;EpV^I3;Gh_k1~;1OILZyFQUou(3nJ#{crx+PK!SaxSc zr{3SFEfs9iUBPYc5fL|KW*j)MMsC)m+)}jQ*tZtTd|qQ};+E@qEr#ru$#GUnd|aKr z`JNIZ9$MtdS@PRH3q)E2f${O%D}MC$;Pco0q%PWUGze@#r;48*&|k7>c&rB9Lns^` zXu1*Di+}qFIo_Y4@7{-5?!tadJ{7=lc)a2lAJbATWTdQI1UXqlc;0jbC9L1Irod&9 zc0pFEAe;un=GOpX7nhnwX&jp?E(mwqOU84p+*Q!)yU1GVSyKJ5RuYMfdEJdnEcGj~ z;|MTxo9e8~)zl(f4|DFV2;-lg5dF2;Sb`Uy^!c00Sz=PCi2PCOJkb_l3xJ`osnn)9 zPETzMj`f0#jZ%GkdQ`(goElg8f!~=mad3QGoXJFXlxW^1eWS24szE;)NA!{jALq6j zHyQ-RQ0{JZeqT1Hr${Jd zKSgZ%{r*C!P%7-3JPmBeg2k?i+blRux_-4dvoK2wZ*iCd%;rq8+0bD!#qWwH8TIPW z&%`6@zIvmGOyQoj=HQ-a%iQ}vy#PLkiWR$?cb7i23{%i}2#O@TK?r!TDwAnO8h+hu ziqwN5jggHCGMX}FaE|@Q7m-aWq2uX3DfD886a`}AcHH7sueTF!P5MnR2ED1-b`bnp zDuyMBz*=L#1;xl6SzU)Ccqi!t33pdi>C??cUU6uY^w2i1ujYbqeP{W^FnNE7i@30% z2lzw0+b6}O2}!TX=MnyyFa?ZUN*V6ptl*gNzz7^9^#!*v%; z7rBVhNXFZGQsM2tx(pY+dYmXNS$d?+T*kfM&Bp$7VyfOgj)TNER%(1R*FA!sf6B24 zF8*wSblyIHif{amXZ}oOTl)*0CKEU*tE^(lJWtOeD{0y{Qh}oZ`JCC5pFB_0FN&VT z5{kgdVL7ph$k8HzRc*a%@6K*Ccde8Oj(LBDNboYAc39RJ<=>gc!exIHUI`u-Ygw&N z=$p}+(QLV0KS@oVVf#yCHn@$MwXy{0%<+W@CljJ<^FH4cT|G64=n`LsbsX*?YKd=F zE^;%yzOSev=&Ic{lT22p3!tkq+E$6jJR4kAt*+tS?^LcGRXv~^<9VVpb(;r%P-cSh zJ2Z<^WmRl#GuyIGvy(EVvCcnzOTN)9ngy3$T13uL)eD>>Q8GT4#p1+_LSL zy%N03$|Z$G%@4|*O^te%^4(5jr<(}SbM|6J;QI-;dU*(WrLbbDr^EHxS2N5-&J*YAWhH zn#P)5VH`t;0m{zZA7R34b%}dyf6H5j&+#@~vG%0KM3sDZ2>rX7{P4j0YQRSXKe(5z zC){BKt0S~Oh2&Cc?32mYyH!-z60O~xn9Segkj=?AKA!#11pjgFuW1T;Fgr$i$lG9N zh?3?Ur6C@Vs!#z`f?EYHN4dlmP1bo*_Hzf!&Y zopg$gCtlT&nq_`TSg7DEpq=PLPn5Zzire{(Cgv4`(%q% zB&K!`SEhV?FJUO{>wRuuCoF7Ce*`bnU-hFWkG*=8^8@wtxYD_jF(R%tin8+RM8H~j zxaHan!VR{+j=g-nmAPVvUyFXjl{Up$e@UBk;Lo1b{vGT=q1&4>8@QC7`zb-sV!Biy z`4pX!{#JkE(RRaU-6&NyNz=~bCk1SOcdD`m)sX}S7`yc-+Xy&$T}nkq_Y1e*igzsz z7ih8H=DExrFl)ys^DJ59fflhdY+|iy?b7Jd5sb*bMpWF>S|{gu?=cuQKk}a9ZL@mk zgE0@7^|V2-teP2nph8kN4|(xHT;AS^O+8YT&%K6?QFEO|BWoKIQW(-lW1)RbV6!2D z5u2Nw=c<^c(O@iQ(KC=#?{k+)OmJUgs-XGf{7Xf>&4s1uH;;00L3_Ot$S1^f%|!8^ zbghJ7OGNu*>W5yl>fIVXr-=j+75UiIEijCY@HKxO-r7(~+(R&LXI+J@o4&3ymH^;jF|MybK>I{7-oZjyLl z-e71nouPTNWoDOoCZ9@pZ*MH;eSxk6w9LZYg&K!)XVbcGJ{BjtmCFO}x)4l9KTVVQ zY(HBj0*c&)7PEu&W#*@Mj`CS=-EXo{C{|oaqn)oApNAu%o9vEjl+*cc&-L~B16CH` zS)?lQDNhtq7FA|c-0LHE34EPwc|?)^(4^=UwqVEK03(u$WbbP%_~0fJNQ84@4z?PR zN6Ys%jVhtSTD|l5gl8SIPKI}9M5eU5Ez0+b#R}f5FH8m6_s{Ul`SMuy)|*3vE_qH> zCfma|=19kqmZqNte1`F>5NU&TXJvdI$K}MuF(xJmDnGY8?xeO|`X+pNtP^U4i-}LX zl6QSv+Z?`shM7#%qC?Sr5MtZUJf1C^m3pVGz1vc>*muvmiJcbXA)7cJm%HP7^qzqR z>Z$Rf6EW<9|1oup}i?t?4a zLMQk?E!64v**nu|Ys1Mz9!SvdD2a?5p^ZNg(!-(AC zgCp*p>bi#yOq7+oIe$RtC7sfJRItGl9p641QQ#q^-Q%k)g$uIYTq%M3KGUo(i>&|#FL#JEH26usnv z3cy|hrRq1`+l)=G;8h=)eH1U$n zgVh@GuBowe{no8U+Kc$?a~Y#5c4|>L<{pw7)(FC9uT=Y!LE2Y2=JE6x;~FEb;5O-e&w7{*=iYQE`=SuqL|S zq2O!>{;|s=4+&g-GF%2#n^x~NwKiVJcS*0^z=-UKeN3i?Ma(Bxw`(e*AHIjLVKw=9 zGV|UK*dz9P3_!ez$J)^ryWPq!Aj<+`Q0JZ!e7xjK5r~}$;v4D<<~|YPQ@)Unc63G+ zd~oglxh@&Kp&O6q-CVXXQleLYS?Q^xGsE#7A5%9Jv#Mp&?TrI$uj?&-cTw_LDT!Mu zAg_6>+RKML#L9_}vGmB9j%6mb&uS^mP@E>ppFAgTYcGFa4r+0g438N8WK;38r6@qr zPmX5ER6^L~r^Ne`+~I%&de?OPGh8SExc>Ewkb<7+ACIPceBc|6=;7bw8yL!tYntM{ z8tw$2Y^_a3r*TLAv7&^diZ(OGd3793+8|{yPW#x``?;=VGAyOm@Fb7~>>>H^rJ#cO zi%;~D!?NPqSNrQsOc*|{_Tv8*l1}%{2tU4rMWAz}ypuS+ns}bMMJF5$u{EnzU%a|@ zOGBuAnKVX_3Vq?o^B73-7CjK0N+GY?jBI6@StT+Qu z%~*|X2pnq%!Ps-0Dj6-e$*z+jzKV;IeuT03o>Z%tN%tt%ziG?6Us{Qa>y2mNVu>Ks z+CSp{4G=9|zb9RF#9n$*mg>~(rJj-^ALc+~J64eN)1MT3Th^TZaC19nw$mHRJl;lLQ#h>~i4@EC0JYTGx&Xi}9>V#PEU^$#8GR{fYDP}$mAi5DF4vX?^ zEMhu_TzdI9e17{uIXfF%FPAr@tvYbI9%cudqEPaqv<3moV`!$%I`>+gC^>CRl|A3W z>x*2;YGM`v%~z(!J!&()xajM-t0G8ky(izL(ukiFV(l46!!TE|Rp=6g{#~qh{0wtn zv7+*d9{a>ohubrQ-mYd09*>eH>4`^M2(HiviI=?D1~a!;qhoMM-%DNu?rrFf>@kzR zT%Mb=WeLuYiW&8C6w2AKWlM-A!1FDZ) zmFU0WcvL>8E&q`AQ@em%kft_Yv8t3;0vestely_9qgKzg@x`Xx3m0sAYl`-|$Ah}l zYFy45M}f(9HF@TIA5SXB|4!-))mqKAyucu5Jf8gCz@-m1|NK6t+A&vp65{X`A~S?C z5g`Rr9}z<|`t@`r&AVRqmJoBq^jqg!exdaX^NF31l~p!M@sSDng4DJQ$VF7`?s}jd z?(zz)lO>1Iu6A}GlzF%qF6V9O_VvScPyTiH$vrTuW`~!gx|$oCleCFmU+%fWJX3K& zyI)2f#>u%N6O+g)xDJta(Qzn2rL@fYeI)JNWAA6Ktc#q%Ke?PtE2?$!;cv<$xV$?SKaj?T@3_t*WTeAp6{!6*zzY)aiv|- zo2+?@Wf1 zY0X17(EMXoSG**!cCy5|x3a^i*1C*K(X%>d@Xo8VjZEIVsxYxlPyivvU0$dA)9H>`r_^ zhatnZ6W08}c!+C?f5eK?^b)jPYAv2mOlH31HBp^5yNu2;YXLjWCAyyl3;pH5GI#Nq z#P8SMPt=!Lj?ym{2u57(IaYz~eo^gJi*pB#TRweBqBl5^AlXwzYiJqOsyVY_ct&6 zdNEKvy15(#UCgII$Nt;%&cXXn7F{BKlCScD{C}0pBvI6%Xhu?{J^dqlU-NZc<0|A( zh|h(Xc{csLMJzHQaQ{I*ouo=+ck&+5xV+4?Y<}+K<2JU!L6Ccehu94k@4FsYH|@%% zPZNI5q4R6i+df?8V)J@G<6q2^k>fUGp#|dJ<92(H8-QzYaRlcCODcwSyH<6MxV%Lr zm2@ZbyJf)`(a)w^t8>%kLEUJY}FrAJ@{DAowiniY-sa~Mx~Y2ETX0ZG`)el|2E-S zf1BIFdSk#rPoY^6vPm?>hv zJ+Ggi;QHmom|bJb(>mhm51OiRgHP^b*4DOkl{+o(!k_Q10h&=+YE+!?rDY#S13pr8 z=HP6sm+f1#2YE6SNXk3p^aWbKgCN7qWRE>m$zYdI@~C{aQ*~yAns* z2w3H$Zln%oB^uW9((}_I$mF=3$k8WT=E+%21_U3APWRnJFqOw*g66#TuBh)uO!;(I z_&w~B#Ap1fL^PrsRVo_EyzBip^_qQOy0DG_`aRwyy>x7Rq!a>zwJL{K4L~H+D*zUq z;esFOvPO5OXMpf@$c4ov!hJ~^K+Z5T%kq7~xYi`>qZo35K}v@&B&_)N^p6(3H(jn( z#WstcUY|DnSuS1}E_Z19&gPyNgkzzmN?=C>o$61K1Iu*A1t-JG$K%PS!peRr zGY$8%GrMaN8n;U7v2+S>}um257IT^U0ZLfp`);%-_vzQRuP z21xIQJJ;388SE{>IxkR^oF*#A>z#Dt<0xJY8{Y@0W>5hrK-==6eou1NDFP#t(=M&P z9EzL&x^}T*7E zK(grGZodK&*QH=f3n<&|HG4)mmV1*j#Y=nFb3&tM>-^-pn_7c+D)x4pSGVrC7S>Mj`z@+? zH4uco8a8FAN=weBBp+dTXn|KGv3=RW!vl{?1h8rn57liYLpv>_^hb7?i;FF-KiBac zKM53(fbP$-WPaH2IGns2U~#K_K80s^A-1s5t94S-77Su>oEb#gHMX)_bO9R&*<8`x zEWgVbRi}OkiqQIshq|9FaW>$?zpB6ml0x9*1!b0;oCMF6BYx>;F$ddfhD&_IfCg;< z8uZ+xv@7LV{`c|mO6YjWhgBIs+2@6Oe7N422N^f1-A5BAU4#=zh)>h71V0KFf8AlT zyS{$WuP*_^bFJtEui1Q{naN!1(JC_u@cO5sw6tIP>LHkh8cq77pA>da&PHT1Xa4B) z^(CifvT>SQgQ}5r8$T&EZ602#Pn;a*hb^}&{W&v zgu0{;FXX2`%lXc>G0!Wcy&TcE!|k6myt=M3Sddnxzs;2_u<-3Vut-F)WiHKH797`S zf1*1yy0BP21B#Twg&9GFEjHfuj{WhJVZ9nOQ4lo>!Dquv;1DEk{Zsv_f+yl zq=kTZA==A6cL4Wq6?JVQN~Q?i@2c}{xUjdK4EzcbeB0!m^3_smB>u7JOpOoXrrs|F zlP!wBSM?g`slrn?Bpfyg64m|;Z*n(6eCA8wI*ZMHx?P{YxMMKILDQQ2uvr$r)YPx4 zh=to>lFC}nPEDBzAoA=k_Du*6dyF$->rh?IPfLLN3XliiO?MW{9qNss5C=`KqkDUF z!4xkge)y!;KB&`qN3#eBP}F#)qRfFvcx?sfYkn7LM!z)x%3n6xq$;`nT72{T3VSKA zEY~sPqLSOzi2#G;42sV4j>AFz&;skMU=VNDi}m3e(lTbJU`#U7NPqbb>HLYXiL4v~140tf};^r3}HoEL_s zw0_6MHq$5eC&Mp9UT#!ueT8<7+{kOaV6=_M7;iMTbcMEk*PrR6!0~LF1$L*h)g=zS zjjcIVj{C;I7dmT#GP-7bjcUA{69ZZZs_ge3dNot>LJx)X4!+JLz{WWNpD*5OwLd;M zF>XImRs;%?oeMgPwaom>6KTVro~DSK-O zOknd*ePnGTVh@OSaUOL5 zRLUy2{2Z=ct7w!vp;Gr5{*sY!R>@YZJwHF6O4OhZ1hxglZkTLI!>Nm1Vy?Tw3#NDF zuCbweCKkzw0A~52H6SftXY?9{9zaDVODdVOw72sOidNg9wq{jbBGgdA`%fsj78trZ zRlio&sk2Xas|~(+q{oV%*mN|a~NBw zE_e1`@|rUHBzFXvAw>I5nWENPsWU!uIBUX`m{2m!@m>2%sji-!T*EY<5EqA>7FgIJ z+<6{KJ}%1aJkYRx;(JCnj|ZG0f{xuWU31x|ADi}Yh{g>1BcI(i&^VR9Ry5c_F>q@5 z$HH?-9~0{zW!sWpSF5Cu_|>lmM|}w{MvvuOtEr~agVu}%wN5~W6FpkR+yR{lnr)a$ z?+y-~TEBh5n~i!0P~*My7jHqAe`tTaqr1#I?`M1~p z(^vf0gtdH5S67!z;@yAFzi50R4w{nf$HDQqxVZo0@vmRM{%^;vp8RLO*3&k>rW@<+ zHP<*rL^Q#cxy#GT0saR6-rqaEt4lR8A)!Cy^btxnCE-7VIYKeM>~{9gFs~gu|DP83 zw-5ea+<(s9I4A$#?)C6LJIMae`Y$$ncf8}0lX)UqbHLu+a@Icg9;buHbM1)ALwUVF zixKkAVic8^KZDwzI8uIUW#HNXLIo?yk8vYXPW|(oL`)b+CXghcC^?BBP~=n~StV!584L|5f@qN> zOQx!zkXU3?S`d&-kqbm}ktAoh-)`IP^X@zMjPvupah^YVj8=-;d+jgGIlsB~I;0I% zw4T>DZ}$#S4+f4PccWQuntwrS%&g{vAc@pipg=)QjbVLO5k5{&aN>z+F(rw0!;b#( zp>e1#4&WJu4?hfH`WSzB)$aD={{;6RMdP4hQ;4$rtx+v;)7$tvAa&stg3cpFddV#f#KfMhku(YrdW}}t0YZx zD&GO(T#Gah7MB$Y%b~hANbX>USu5ISA&Eo6Q{Y?W%fa1?Y4M%`-#zD8om^i5m8mQB z^hX*|Q~i-D7dO`nUiAs(h98VRHgyjFjDZgcOGK)Zw%X9U97&yWn4_&Xf(CbQBjedz zF)!V&lO>P zp}Q-|cOpbr!tJ176jQPwF;~F(L*A3{>x0fxVYCUK=R$x7{uXA@k zW8Bsp?&Jvw!@qAgmYNj~4iZXrBfh%Z)b4$zHa2aOmf#puD*7XHA*Wn3q``N!FMXsW zV+4omFRGMOt7DfJw|gh7?@|%$-~hq^%V4R_*5n z{CQDD|JDAXv(I~@eYO})LQ152hOb%;7oH-$x7cAKM}d&HS-$uJFa-y(P_3kkoa|yO z18`kqlYZEq#bx3V%tasTamq`-!Lc}FT5)|R zf8l2b`r)=58WWZ|D`2nkVnW$~1%;JT*3zHb*4!GY_Wdn+1}CvAy}am$u1B#d)cAE^ ze}M2)xy$2Ac~j+`;+L9h&;A-D$MkCoa#LP$$~xLyQS3Jrc+Z(UfAPlj6FM(j4-bTv zdg8=50MbUcezs?IS<~}ju2vUBIMemq&Kn!Md2Yt5iLKroRbgVCiiz;@Hi884h*K7l zFRE8lpJrUiD*&zd+4ZZ;Z0Sx>r{X?t>mP>`aZ*-wk{p693i z`Vyo+zRjw^uAsOAURu?o=kr-!c zic8qEYzf$aIl1cCeq-9Me4>8m0g}&dwtPY&B0s=SXUsNNa-d*{>x!V*9l=%SQL-?) zRj8ysQB)byrFS(frx5(^@`59eST2a%26dD!YL}25Dt^&REy>;proAArMtonDkUJ2D zqORi6n#@;OUp~s4mc}_AWF6izr|QvfM{3?h1F$`};=yZbR_EBm%);bYoUE=slEtPy zvpSeLQZfl5kndDFl#|ITvYe$-1e>WY?2>;5=|`1+cDilw%P7+=_>^m^;`!*jY@>`k zZ8O{sk+J%{4tH$o*F|pioz&dl#JpC1hsgsBrFuHEj(E^(SjE`*WkrfIu`sy5|Jrkg zR$}@6$o%gHgNB-!F*##wK=wGw=bB!Rowur_VK3l)zn#EQJZWWWAZqAPvD&_oJ6>O8 zXDhd=s^H*rOm(IEa8RGQ(fMTK5uh_cKy_dkD<_k(vfHU=_t0xLWvN}pl7y<|0Ku}u zl5#7X$syTZu)bLQV7=p{^yignE~pY97317Dt~f?Ut&fU?=e5MuKF|h^p`)FH$*4-&dwm(ji*bKlb4g z7O_U+8latlFnhW^Efz`-q6tJ){!qN_VHFAk$Vdlmw#f-&6Zz(F+v+0TuJWB;>haRP zQ#Wb-T0UY@x2udMVl!|RrI(;Cm>OKy!gRoNMRTVU&k;TU7H0OQM!bfoU13C0kKrH1 zFQlxZx7V*3Rfgo|)|cLo!yG&s33aO}?OZCC-SC$fqrH7T~oe9lpB)`dr>#l6tx-1+d#6u|yWC?7|( zkW^ehi5}!n zU;mnM?YW)0A?uuY<832Myp19iSnJ6x@1Tw`qgvxjzZ5+%z=w=~^ynydeqpmd<*lH< z>|&)~D*dVrV^DA2R`p8z`vY04MMeRu>d~_>HHDF!Xlmvk(jaDJ*{M`qgi%htp}Pz^ zUqT&pQhABxqgBorm2;`wG-B-9E`>$nJMVC2KA9a!8JSa8Zkjv~!f^RjbQ&z#qtquC zZx&d}Pl?F^L9avWrEGC>17+!AFOua!dd=J_4E*QxWrRv#qIVu1WbnhX6;aLfsx{;s@o%4=33rO(j_N%%+y88i$8dc6W^B8yWk~fXPr{o zznh5(rJvAiR?8!a#K8{5GLH~gNl@huZa3e5W#?u5OfvoJHJ9Rz19cZk-kYXnn=j=R z)q9`c3~$-J4%P@o3=)ydh4dy62kg&;bfF?Wn3Fn^b96*8;j+00_Q+hB#!52RC9)J3 zxH?FqA6ZSnO|VYSgI~i2n)37}0|iE;T4th`-UpKutIG8|&~-3nb!T2HZ%J-<%wWe& zuScU7z7FHu=cqql=+G83t0ZLp$z0{q$4T|^biZ>5!{@s&;M<7E2U@JQVj89iZmpoi zEeWuGbZk#X?JEkM>{Ze^8>&6o(2U&*`7qdbvONutICN;|DzM8@uheFUMjWdFeG%dd zii5&B<3`Elu{$a0PnCK%zU)IBH>QBl;^({svwZ6At9At@fTll$DEVN=8i3QKW)!NR>+4MK%!M(+d-7Zb~ULjDp+-iwQ-9im(&W~|oQepa=72G-OO>Nk8=b>=a0xhoM9w3rq_mn%50U9Xn2VhtKK}RogMbilv=>FV)lo@r0zZIaf~n&-KkhJ%?A)E3-L2KgDZ ztYKOTm5xzly;1UXy?E-6`o}QoIbv7RwCc7JuGd!)s2eodrnK^@k;C&FH^?WKP`Hf| zshQOtH?ZqWQkF84Phd?K9p{`IctE!^;~Fxq0-kIXYt@b+#WNw?0uU9Go-XXq>iml-Dr!1p)A|ZJ70j% zrj8b-Mz$T>z5D3A@k5ZKvn!eOR+Q)?MDM>(J{dNG!`t%Z%!q~Y zHi;Xly?C)PT(0IA?jG2U-`P5~>^{$>!(DM_J*?JW^O%&69n|&$%qwv%ARSpaoeLZu zAMV@6-BPyG`|(ZLtBWhV{3pMVF0HbCojEdD>r;(FVe<3TLpY=AfZ^(m!4OkOg!NJ1 z`OwV(svvo#u+hAhF{>Ulc+b`v&kwUL^H0+&4m2G1`ylxjP~bUZ?h#G#+g5Yfyub#2 z)hT8eWG=rP;}j+izgos|i~^F|dU6mCi6hmdQT=fDgMUhDchm)x{Dj|Ih)=Dt;A^|a zI=Np3m^G1MH0bt5QbdSH#4|TlVf4krL4er})Btn_inaPI28^!&8AkIC z=Q2_qJrJ$l9>MPz?r2}EG{qw%OVHh|>Bo#GVDLH#!SjdZnXb!${Q<1RRXB&9ImgHd z&vto*sQlz|(za|`y23(J%BflLPhgXw@%0OIq$%tG%i)~i^|y_@dctf;gGszsndLx( zUU=|4NWH((M^o6Tl*i%{>ectleB3%&YqqBK#Aes}JDI@UIRTtfnkDA-3~#RHKfd@T zALp2{R~kbsFF3l;;k#PME0dT4-Z|Qcw`|5Xaf9!zFi2#LlHJPyUtb@&sCH*i3I-SR z`=2w_H)@^6aW>!+KMd*vKi*eu`8fSFc%fZ1llFaSPgdqg z_`7NkA$bMwke6e&Qb$M0&ay4eAR9jAwhzG*LPS6R`6WD(08pEKyFl%?Dtj8-Ti%;` zjc&#Nsn<$~xybLv9I*=X@O*NLj}!~vS@I>7+zL-(uhUh?$mxq}g8jc(a5cuCveH2% zFT8LL?Kd+o1Vd+UFWbH{7^kdR!Ipn*;BgGIxz5XBw=x*DzV11B?zVkM ziPS-%XC1`8h9vfz)}e`0s#6E5yvw|ri^T-X6h?Nx{LStmyyXum=zkqJZ(O7f}W4{w`mjme;j+FuNJ2)EoM#f$h4R;`yrWWK~Wz@usO+ zKm;70OQRlT8H(M?)-Hq@U*%#iv<#Ep&Qmo}t}lo7ved=3OU?!y0Nb)@@ONxypfg;HY5#jlvRq_ z9t|V)ihmmGIq{l$8LUzNo4xF;;^elPg&2{!4vl6HZJ=Ee1P4^*?T@d%2HbShnPQ+d z<(0n6&cQQkiO8LJ~6y|iF~Fe2Y&xRJm?!2LCdRKA*KT^&7I#|o=K`~ z9sn!S!_t*Q_w1UAz8pVwMOJNyNvvzAgSt_uzP^0C?*5Oy@fGV_u^&hcZr!JWUiwB3 znj-x*D-6EbjJC-c5b?b3f3d^Ud(*eClz%8Z9>{Mdz!hG&wMu)5w0K^1M*DaUH-l`7 z?>08%DA7msRbHM}4z_l5VamTd@==0bUF|W|ttURCY@Fyo&Wy}&om097Zf*uYPh@*6 zcSexAOG1v6aRJNjR{;j&73Dngoi?>vHlL*)kqFmeM|&ylnhB?Uz6yfHRS#FmMj=(Z zJkz%{4$y>w`HlD$QaUxo?e{#OiOAxQoI0>>OaIfA^dQPhrqINoM8B?<`Um}|#(%6; z12TeB3uy=?|2)%YUK=2j(iRYM$lB=eHcbRHmdQnxO}Z!4t*t>*WY+s+5J6rGv3BvO zV5S8Kd*L-?6< z$FKiBS;*=U`1zLuXvMZ@1lq=>Jc51^l`KhkD##Smm)&uRb(2d4+(fU7R*MaSW?I|r zb=q?G?d`l-Q^Nr}OsY)bxIlI|$O&1Xq6hK){ITkK;nj$*IQYfksWp1-YZ}gW3EyLy zsm*TT7Waj+Zzeaaw3G}uJOE=|xr8UNkf47RnhLluTj$2XL_b>8V*-Z7^p0E0p2JdU z$3)PMWiV)yH_Z{v!S&>yZZEO!E;*LQM6=xchq^0lX;BRwHv~^VlqzsO2=sc_DuGqu zEjRg7lb;g+T-Yb_U`LWCMGbV`x+1f*!Ir1l5mM|+&HYCw=R zz=$H*essLFFydDLt%lB`n2CC@X#%J0rlip&o2Ug;q@mO-V#JXh_A-FC;vQ|YyJqCP z_6Glrw~Ca777Y*!(!NiI%45E-%v@<0)y7(_)K(68WpPG<)Zi8&PPz7ZX(DaIL!yq{ zkqW{=gz$)oP*71O%u_$r+w~|k<2KuQu$xO!n(IR%eF6Q#vfwy5|FGheLzyjq#Md~& zF>OO0n90+fX^cW_E-GSDvo$h&+%$gf?~6|V7q%w%W|jYw5h?ADLY}?=7qIkF9?dA> zws)+fF&g2%H9EXCpV>ilMBIUMcy|C>rD8i-90R^eOz_fEki2BOZzq7V1x<3u>rGhrzhZa;z(BzNDCF#n+^v)RP14h|Qz$!Sam1{az;ib4T z>7d-HeM*vLE7G<{EyV7-dTt6w3x-2M)6iHpNE&(JF~QFxE=TS)+vc;w`~(6PKYdp( z+`E4d%l4EzBK1LMH_69sEuW1LbA1YO6yLR&9As;PLz6$wXf2j9zDu`o1Kqr>cs`|B zEo$=ux@U@~&cG;Rc=f;#Rp%(XUianEMuPTB$L@y- z$*PHZB^q`S_;g)Up5}0n6=AOc7V$(pXx(}|L>o^+Uos%B{H?4XpWNw@yzD_A+Z8He zq+`sj2AW8?%fM&H@Y9fUX@8Mc5+^M=Y53KHq@yw{OCr)%EB?-!jzO@=COP14)p}Y5 zqA#p%?uPR_7VtI+8`ZqM*Fz-Op_Fhu&^_{(l#0vtS!hvUq{d8xFi~hzzQFbDD9-4* zir(&=_~^i+vi_Bp1=vGO!{M>vkQT~-?5Z%)t5P1dFTaKuTtMt*pgzG^+7Vi?qxA4+QN8_Vv5q{Ms*wv-CeT36*cAUpqxm&%m}9j0Jy45T;f*V?7L8SwW&5)prPioIp_9 z{F3Y_4o|&#?+$v&Z?#@WjSwy=h1hdoLX_G_R8q?kkXUJY(;ko=DtfW*UGU8vuC8xM zxY*T~paW=m5C=vDSi_91VG^|R?pl;V(p$UndKjSHZJ%9;-Na2tj00X(?Zu{qJAOY7 zl|*xWNAJPG_oJ?yyY&jljtpMqT_T9Erh2=9{00&|T~nrR2~!jvGfvRXNuZt6_IoRR zagnOtJ%jTdvaheSLADWUZhX)0b#5osSB^NQ#R>kY#|;JJ#|{>^;~i=5x1Xr|W^|!W z{(?t3{4CHUG)Z<2J53s|7Ew7;!9+8P&ThVN+GAP?nn;S0#??al z3A<2ykrsVTJZl4O7aJfu5R=0|Oh)`rMsx2YN3VDHKbR3CBDn_|Du-X2pa;XieWSYI z+6d>4T*@PIW3PAL@^6*_j^sOaf9uH1H=XAOd{!0zZ2k03@|p9H{a``W!#`fi%SVC| z*`|NP??{STdU(g%uR6%TSv96H@q8IZ&+&(3j@CODJMhsA1}bxXu0>Fx8ZnRZ7&h}J&(yF7Y4&7 za_m%A%(~g_1gHxi7qpvxGNUf>)^@C63S)jEdhF2+xr zv44N!BJXkdgxH@t1}tdpnHig(jB?Ly)tZXTvk@U z2I{8-xu=`TVsYPNzexCd4tDmQ|Mc&#UHgCi{F$-;Xe~huN?t2V$_on%Cb{>*6A~&j z-2T3=`vTakEKL;Zu-NlG-!4#84*%<^`Ih59H1_v>?%DJI5l_nhwPuAYN7gJ-&OvkHxahXOK;m?ND$r z)u-pL4jsyT{`7ghARFOZarL1ib{7Scui3pjG|U^6Ecg_Cg26=Nc&3qqB*W|YI|{26 zmSI=dx79rTl+Ic_iDYT66%q^VA9a!_DM1OYMy!ti%&c$u$}#enB|>Fw8IKvLX{JVF zv6Op;zR6|(7|b5^3R~VuraUFvk;5Wf99?IbyD~E6sN80FcTO_CIz?vrl^c&vS}hEX zS#4b8ke4{P|9q$t<|O=1oqL(erlvIu%z;uf^$msXgHAR_l)dD+4*XikZMteY(h~3@QZ$+F3{TzGfBcJ5mu0OoXA565-(%2DxZo-x%7n z=F_YFPxjSp$@Oh-FH7UK3}+6MkUia+nTF?C-@Msi=54d4e_5M?KSJfAr4x;*Z$N%o zSF=jh?*}ODU*tyhuDPrTeTBEr?%yxmV5jYU*FTFXly`fpV9=~R@BMXKszcX{sGZAP z)GV>ue6Z(~iwljZPTHk9f+2t1C2Z5iG_7L^VnT}{bwbm43 z#Jgu`(3qOhNoqu#QO?j6j4v%tmCmX8Z0U6WUDrl_ef`qf$7e?}9h7^88Q{FTiMH<# zvky~~rn~Q<)KeSM4&JEu&QT^~pJ@vp7J0kZrE>eWQe@)&#ExdNx z*Uv00bZ^3pf0uU6*MU{6z&-LjbJp5KD^k1fH#_202GI%HEp!xzfpQq|M zIXWvzK4G5Z`TTi9^zx^-s}sFX_xhLV*Hp$neY)i9T;Rxc#^65DT2o6a`$+n5nzq^- zSeTmOsNwHR+ew$^bwwCFuZkHAaYuc{1dNVZnR%BpWAn+gr17Zl!-baw?=hi|Nv?K( zd?xe5c|B=77T)`tzA{6C{8-hZs^4iXEmRM6a{1?E2Epa{(-G1n(|g%5-Fa!S50Jj0 ziP%SLl-)K)p0J2upc8K7vh>vqUpR(79nEZ@mtC6fS4c@c3dhNPA9nd*6+yP?-1v6C z@#PE4ym3c#q4DnN(JD^_7GxTXvMKWXW@b1mO^YSiLa&m$2Els?X zRAKd?QKVJ%+&^vl)}JUo);F5xH>A;;7UqSoGdEOixWiD{amqQSnDDvrj4x6offs?D z`^(6Nv_hOxQWXK#VX4cPE|s#1l%a4hPU4nx;Q^R_{%klUgr&z=U?nuW@$aYr2(AD>b& zHI;uPylf#8aFvz$oTS`Ks|PRPpz3|H=LiI=%@|4b7?Q5F9+87HxECEOFya3EQAzpO zX(Y(cMujuDCgOZ^s1CRC?cZ;BNkbt+gNy125&#TW!-{@CU}b8NseX)F>Tk2!#z7s}&GnMOEGW>$yIb5rldj z^AzvTLkfIBef8wm)tcMk zvAceF2U^-ZcTvMdx_EP9SBa5-UFtPF>Cn4gAqE#a^K7k|5#H#LNM{Yhgso#-gJ+Jv zyYTF3xK+NTgY+Mt47ka!qcWgkhHOI`{aZfXsm1sdmsIC!QYqSyp2iyO+h>|{rZf0U z-J9&*qE=d3PY-yP<$Eupcy~pg^`RpBPoIMQh!6&$!AC!f#4oJ6FO6ePhJ39Twn@Zk zU-2*1FBM~zeZw8~NuS%mP&2j9(UC%S^Lg}sey-~DS-g`@bk~OD`npWw_}oCYR^ZcA zrx{o3rsl41Ti)dO_!%@BeLvU2`u1aOk@GG`QQDuYo=DC9TD?7kj_T->B}!K(RWsEp zKBa$n*!t;gtKy%}*rqC_@j6B}C$85~fP7faj*D6$37nbYp4@X5Tva<*=82+XPArn16&w*R% zFFN?UJKCVoMbOz+@=4eHuj|`$?aiAvr(zfOm`6rHz=A$- z1!A!G_{qzt1vn*Fh)qs!@4k?A&!_!%v}@~@IgRNoLqncdxSOoqi#d9HeK*9Jr5^+a zyvRq@FD|ybUAhF#J!6)IB}n2{s$SCPB)X>?E4%+8K{W~S=9KCYJ>_>juO zR2nxT{&DAjJFc<0GUMah7L(D|?lPNnk&^_+PUsTFKZ^Z2Q)`cfE-UHLzwaBjVEQ+s z_J}|K*AD;T;E8|f%GyQb7mKl${rt7KTrUM@3~UEjg#9rCAQw zgm~_5ud<-{W0kUX3#|x$&wb&CZ!3wiwhqD1((k$o$752DF$!CBWU2{IW#yk{Vj{%F zF=QK*X)TU5YWVtAGFyHQqCG-QP5K&rbM%qmEyoc#vGtL#jjG+vssl5-9U3Z={Z7rK z`Up<6zJAsvsQ}~-r#iFSU-DFDKG}EBYN92{swXe4$hI&2{7tioP>G%E8=iG6Q4^hd z_UPJIjA(WB&`44VfP>JN?oSXd-Bcc`ncOOgTu?kT)FxF@L0i`;dPOzimvw(esz18q#3vyEPm~he`eP>CWfRpRs9G*!B_(7@+3rifA}+L2B2d&+yK;5rRG zMrp}#SZ{BUtzOD1{OKAFl^{&zR2f!nExcsL!bQYL_*EXDrZYJj81?$~yFdSw-oJnU z`Adz+L)h8g#KR45Y_AT|($V2IXUkN)R%hYGX1c|Xk|KGG2HyGLsg50s{QR(WqBVuj zXTurQ_TkAs7SzkLJjmsBl)2e&FSV<{!Xw#{I|oMu=fKshS{PmZb)Vm^D0e?{x_kHT z#zac!(rnqBJO&dMR_Hvb9^Gj6xuymO-$;SoN~!hrF&{qM&N&i2GGfAY@BN1<2ji99 zp}-3Tj-&FZHsYW!9yx&cSfCW7K^WPt>#UJ?lAe798gu&O$>bkD9{C3Z$YUNpe7N+Z znnnQwTdJ5R-&dEpOn2wDZYsaM{_)&p65VZw<%Pw|AJ@wHR zH>fk1k$Ly>BKy{4|M|HoLK#8JOHxuY=*5d0y}~g*`0(&>AW@iG)}7Zv7)7+q;~WAA zC1Z33PK|t+)C+!FYCIK7n67fvuS?k@+h%h~&CoERt-XE9K*Fkn4GDw#uU|XX=7*Qt zRV1+5IfgNU4iDq|x{f-~mH8Q)nwXs1t^6LusR@6^T_G`LE9@gmpQgh*ye4z}w(+o^ ztRqF1#=vtjt#l&!Wk;d4wo)|j=eNUuOO|`~yt@=*tDJnrvWpPU%q}Koz|PK&8R6?v z)|m5S7IB?cwY0=CPxe~p@I}%<_P3`iwL`oaS|`4H_bz3CSPIdVZCIg;*Usq_g4Z7I zhjZ|r9As*#+T8{}ZbKy3=(yV_nPdO3jf{6;S9QAcOkW};26?)8t!&Kahc}+mqVjrd zluCEsoIZPYs!?FD1Hy0l%azcH^jJIf`}f~nzH$W$JD+76Gb+M~tAuO^u@u52rhDVY zjSYxL`T~0&bq$R|+dh6I-8~_*kWYUx;XS{|fLtIgEiIhcH(Z0Y?JFwX-PsJCT>sVG zAGi0RG*C-W3Mb);v>!Z3T3B3+h>Od5P#wU|$A^aGFP+Y7h;iNxxcC14lbX;AIZ!J( zMMd?YbkXzMv`Ep2rKTxF$>ZYeCnK~Cktg_lc$fs$_5iq7p&OBcL zHx*vQvBua8c8&CNeeoV+(Ui8dh8q&Uw(ISA4t5Pm)y{F6D|$Fp^{kj-Gpm z)yVM7_8N;e52jCrUaYn{OOD0sDP0y4(%Ifz6|n0D0CLJT4yl1ickj+M4&4wutN-W; zeEaFsr=5Wwa~t3a3JOxhn}*;!Yl|P0!Poly_;|1sa8As~y^J7$19amifJ}7rOkN=U zthl)NRA)xKh?~_ig{0KBf?-iW?gRkIxdtYWkrQdDsS|U4yI$Edu?wCynE{nJN5>h&B1Fk~3%|wpy%v-WU$w zFs_*hptn<1R16L{#^}l5Mf2C-pHNpK30JM#r@C_N^H$p7{ISspZ7YCF(ETWV#2nt6H>diGusOBEmLH$) zSMm)8FxkKje> zv@W#i;Wcn>y<5W%QPd)P!6#MRXJZ1ITr1SqCGf;>+`6TS0rbXNu|5{7TjrV#J)Tf| z0Z#SV*$EME$}L`~=Uo09KZ=bNK{M?raWby}Tma=*cyl`6+O|FUE)|0Gki`mi^6Jzv zM_~xX-;Y3CSj_g9U{XnVZ9sW5s;&K`F>;QEeazlV;CL4IF zmgo*N+3-(6%#gH=eA1A=1q`DN$jAz;)A$^#{$hvH@dU2|SP>+tAt-Kk(f~r6s4aFF z26$>!cnS63!2@|Yxz{ACY^RDI8!d=eXe(3tJSgC4?Z}mQE?ZSVqzW&7y%~}e_R3lL z3eNy3ViNAzUlg$6ZbB5pzh%7?}uR~ zav|HxIeus;4q60#?BP>azu4^TUXTPNq9VSYs+D7yZQ2lN`QzKi!hk}(O7D`5)j6K9 zx>P%IsbzD5SbbyTvT)}RK^D89>$SbyRp>gS%cWne>bKc9NXc5!foF-JT%`n29poZ# zXuNZuE%CgqV!SX~A@Uj$00r*z50OjYTd|$+G9UXIEn_rAXGgfyH4HiK48&w=xyz_4 zh?cY9(a@cENXf!px{E-9@XGpa-+^;e_N9|oE1=gRNE*4_=$qy^cyKiv zxyH2|5HYI*UO7mN40}e4c@^a-3vFoi-%3j z%_Uwjfcqstj0zZ& zLR~&UthPOQcijekh@woPJ3FgYQ|sf2U0>c@u`aaCz2`jSf0pCM4YjJ>owD5WOI>wv zF9_)YhLG<;=& z6^VoFIhE}w$RMNYjxbULJyP+Mzf1JpKta3Jj^Z|e0#T&fYzp+abiRh3&wK6B_I&My z!p=Z{e|CsQ=#-W%ub)8u#&Fmx3M|EGibZ+NmdFB3N6I7wwq<{D71Rc2`27}8C+eH8 zAKC&n=jVF?n!5m9Ta#7efi;aaC*}-Q2U-L9cUj@OeECjpZY~|K$#Z07*B8gFp>5WL zkfF>gL>ah7BSskoC0HM>Nt*tFQIvNX)RRtbmVdc(-HQ9YQ#ITQH)-yb{E{wGS z%2$)foTjw&9|?f@Je$X1IhWxnOnp9Uq$wx&9N6s@>_WgMaJ0hvANRujOvX7iwF2(X zes++?iiAT``_94Y$*7y&(~Jr*4BdC&XbaLRJ32H&E=U-jx~kX$YfbSnT*l5V=3W|4 zh0+_tYZ^4x6p!D9XGrPHj;#ry&nPIk1s`)WwCz|Oc*6{o7{UP%HSO86oFXD+t<5Qy zrd`YO^75EbB_$;x1V`Hmx0-NvINYem+!`*jH`h22*yj{&jI6Az_0HCMZ>bADOA8N7 zPrPF7`^(bNNIhm0!W0TD+nvYb-Ecr#43qRDb|IJ%;s@O<%Wrpc&}GF9ITs$72b1sG zgNTR-OQ3Esz>l@PWIaRW19vNZ;VWH8Xl;_mmXYuJT^g~a*RIpuNl-s1xwTB~2vh-_ z?z6c<>fMps<;gsG{P;US?UtZp6cR`Ql{Ly48c&`)aUuL1TM3f+JmgIHV_o3aB1t_^ z!ido6;Xyt>kAYGbYwB~?Z$oX%2k>_V6$2;q63Q3k4P1LnH?|g=D~gN&{R7fsMh^($ z*_oN8;iN|`PtQ{3Nhkmjn(I_+nVD>2yIYI(&COk)5+Kx<0uo3UKBrQ5YOHbYHlM>T8(Vag<0F|)GD3JD1TrAs#Sp1-?1)2F@f;IR&f(IW7%cL<_ioOe$oN>WLF4 zYC>v}3ji96e2+tdk^9ho#Q0)GE-hS{8?+Zq1bhobsm_F#T|hwlg7=RnsR0J8z-`?6 z9RkHSCaxHZN5T?Mb{YBcZ3sJ0sIsuK()+~TFb?osoy`V3*%?EVnwlz&wA>B-!e(eH zIdfIJh9DV4GAkvD8))l0e4ZD>y2vRib}+j2Cw={@n3kT-PpP=+x8n^_r-0GQ)=g93 zspz;TVDja7#d4=!&GHTus|(AbHxTaMFxm#Suh3)B%w@K}N6;RrDk5{`#`^g=l^eJL z-_03aTTS$N1IIcRfPJ3XuO}YCb-qJ#5pdDkLX`c7h_DS%8Xxo_1Z>Dp^ z(b+S z6de%7D2(#CxydOhfVg;|>NKsDdqE{$0?C8|m12Hl{=?8fO50FXjuC*A#zcv#Ch_hW z*Pa>Iij`jL80fJG_Js#Vsiu+Wh~&~+>M>75+NR8P=i*^a0TA#aqN1!P+tLa<`Ih(w zAkSxuhY^MAd}72;#!{JFCzU+f5XA|&6534WYSeb1Q_XpEh#$+LPyU6Sg;1wD^C$va zQ<(U8+y0I$Zs-%y(b13-0+CS1l^_m-UcKV?4e4(g?l4CH`!O-Cij$cI1)7O|J0;?~ zTTTcRLF(4V@>B+snCF*KN5*--fKJS_z~N>daDd)ohZF~b2!uj+cek#T11h5W6xv@JBo5E@(I|+3v^Q5|tsvVG zMveqMs0l$qL2V$PP^L2gA%Z$l{^@H_&=G{ZgRogxv^zr_q%hpc1%bs-V5FhGomr!6Q^uVa#`BWH^|a|136RCh43Y z9S2!1uW!b%_Am`il%~$d<8*Wsv=Um|G(yuuEClNOpwDXj2^&7mz9QRE(3uQ}H$GVd z-74v^>4ZApL9{mlHLc#N5PoN>);Y&(bWZT*VE2|aHx%kcYCT5*B~g!W91I$`hfaU@Lz@7@MIQ2kII>2A)3a`~pK$L|}hpc;ML zX8U!0FII1+p$^V@cW-H0vYWHl$!%4ypzz`xaZn}(|xx$u$h^1b#--~ zJL}_9+b*s?p39UpG+xWYUmAhraAhG$pOE6Rf@yOMt`BJ9XhDx7J=O&?Xcdn@pY}hf-QkZM0jvP@3pxD*V zr<4Ts5?WGWq`-)XF%$?GgFtyyh))#q+h|WjrU)?TIoCY&l!`ku{{p}uUtihP2m^FT z4~PLN>Ii1ayLYXoba+Uvx^e&Szax_%5?_~oN=u6>JQc+&vadvK_>6qMn&sP-&F~`< z(sGk_hbp6pYX>y0hnnd><~i)!t-TSG zuR8%YA8{<{$T5OB`u;kze7+5j9Nj{B7=3$ABn2*!-3C4U>7d=CB_*Gvt0WQ-VH!S- z5GS*yIFD|AiJdiL1O&j!*tEEOpLbsZByZ9E{z2(vdTjXHx0#U2dZFSQnXO|PiM}|= zX9p&OI0>roT=-y$u>at}DUdL1emEhc6L=Awig6}3*1l2)^m-RN5zzSoQ6ZJUg+N0^ zr2&Eg+;hvfVQnG9x%j&;&rE{yqIK`yTLfcQZqCZArd8O` zPlTs40Wgguu`^HOGSMPwQ0m;;k|ae>ZbG67QmbKb&)j4p%%+)(|z3sS|~hS>#zbN z&@s3u?HQqPC8rq~ZGY#ZUT^A#mWq&m&^;yq%4tJf1ka?_PgM`k!ytki!_30W+_AaS z3oq|R#(KyCBAV2ZV8~BID<%8wZVQ9#M1pt~Tj@3i#sLQj^f(?hukC{o-qg0Qf|3#% zL_uVdP~tRh_2KbvFy-r2+b^wd8sP}QSR7W{c$P@A5*vH>cg@lyjYLSJ^|`y$Xc8Ae z_Ob*{>ABP@i$DQja<;t%lf_CDE)*m$-A%LLn&9&(Kxd7hX6fcW^uLjz;@sw%=xV9?ZcfcmCR8i-9ADq?Af zRt95Y0uYWIm=RelO|*sxH$lBv{_~Uy8<2Qdab38RN<^jkUS9WMv;juM&f2T?!f^gsU67h=^WJ`9ctAM}5Pt z!IV39?!cHX{LPzI<{51d;G-Sz9w9)-Mxew&nd^XN+y8AY7<7Gg~0qh9xRu?lVGNVZ0m$16Dwpw;aCzX?EVRpdPv!pX10EW4l@a(tkcb zv9UHB8Y=k5L#eloV^Fa8qXjEv@GOumTgBPA zV8DA(Df?b-vX%waUpW68Rg7(M2?E(Ze*CCg>WpRb+nOH}zV=u{T3sE3uwv+U>>M2O zkR4V0_5o;OwHVCOuPU=P-5g@@@s<7wr zaAw_|WViw-$k_67eHibASp01weRtSHlnLl7Cnu)@+$u6-`}yM_%D|XhRr2o{blmrT zJ0jfEXE-NGt8mSO%AS-_j!}r8|V5dE4d-!Naq*Z8GeS=F8nGlhb#G0j& zfw$e+>9x;~pZLec^!TbHe1(PhDeT2xpZ`|isv97!6)Gz*Iy#w*IeE8CU-*6@rRu>0 zU3AL#OlwOlp}AQ*xjM;*KlaRB>4}XkZ(Hg^ErDuknAF=uJ>uxd#9r`K6tJ_&NgO<2 zWg`=?(@BVrTmI_foIobfFD!(QJo;kCZ*BbOy?9V?IN63k9L%MqEoBkVHt(4^vgBSc zXhlMH4Y0F)>Aw15rgPBI$ptN~LI2XURTJ#wUNE0Zn7 z@8-%}SLEY86^y6azd8M{_P#T!$*gVHG7e+KGK!*rN)+iTqI6V1480>=1?kPu4AnA< z1%ia$2c<|?AYiDL5ey|6YDj1@l0-^C3B^#){eZsf{5jwH&Y$-^XRXh*T+5i`dG@pS zz3+1E>$;<04;akW&4w_m^L+dAi{5AY7MOCLKdX7Lxv&{{*8Bzrf5z+O8hM-z;omhx zsLSriOE5N;eRV?{2-a66>Q71QFKyW>Tk2tjZZDFT*TTq^pE!E)Jq-Vgw=?i2&t13Q z%fyUS^jQ;b%Ej8h`qH1ce+l!#4jDw{kt1T|SAfN80Mu6Yw>c`0p&@-}GOXucmiafXU7Oc;$O1XV%z3l!Yf*#Gv()voatpp1^n%fhPCYxSoR%*2 zq|unA;)vGI$t_UB665cOy8$a+2QS$A(V(V76b=03z_XINK>2nCPWQkgoLLentC3f* zGecX8zfX_y&vo$M?LcMqYW(X9&}nT9ue{hLFW=~^cMP$PG&{?DAgCt)U7D>0d=9wi z(_R~|-leTuXRbQtJeJs3%O_AYxM9QeWyhS|1-1Jk+vr0EgU^oYMnzGF|M*42+{D?) zGTD>V+Ev(dMR_1U4z8jco0K=YYY-M`)YK%a66irX&GAjMoQ|yWGX!yI)%<@cKDe%XLGl7pcc%z)GoM@86kRKRf0{B!V?v?)dyUGQ?+b(Ko=q z;@fx7F}T{<7Jn;=R-^PHVa5Zy4LGEFl=>*-C1oKiyfokIzYBMfRkDG2~}} z$@XR1#ERmdt0wQu?)eUXaM4hIxE9G)WFTy&B zw+;PW;kx&{DTrV^$;}=57cL~zpWV+tTAsGN3}OI7r!X9YHD>xIh@W7Al07LrS!Z6dizNzwpwZ{GBft<*1){c!0`5su}118JOk}&p@({sDy zt&2H&8kop&DLR&R!VfJ3`13&t^Jqu-GcmE)Prk0YkwYw36_sXBYG_L9Zx2xZf(P8_@}FTuZ;GNeZ@%Z6aEsNWv!};MG0Ri zurw>Pv)ZtsW;P}(^6guNg})8%{fJHLQuXwzwVu6ryoDjKWmA5^mQB~i#3KBNvKJp> zV$j)L7w>{5h|#sn#Drj)@LEl+s^6UQ)c5U-$>F%f*1)qsWdzqaG42l1qHK#I%Y(b0 z4L|%%UibJ1`7~E^+hVGb@tC{tU+nJn1v3xX61ay?t&B>Z9i5SuFd>q%1~+UZoH%XN zJC+!hdxTZ4_3awdI$ygpb2K|ufjlxq<_x_aOB8AHINdcGrt5d#h#MNZ1yjBElYc>B zH04KQ>UFncXfAxa8S`Ax(q|Wslf-|2DEIrN;d$PZYXG}P6uG5E~T z%(mv^ODlg4k>Vw1GzH!l2$1!{!juP}-3LClpM-b^Y;|9I+w=|np0z>(MH`-IAi6B# zAAi`pg2Z7&6?`$c84y4zP(7)p_wNTPNb2&IyBLd0xO|i^!ekxJ$;sDx{sl1BJhKsD zbTkmo!<=uuo>xD_Bsdp-alK|9cO}=DaeUAEpFC)YZ1jHavhd;a%Yso-Qlj512MS(X z25LUo1#EO* z+PB}wc_RCsSwVdG{gHbwPH=;iY)vni@?-R(%pPQ9Y?0VEBPlU$8F~#QXX{M%_7jR` zEw8|IiApMu+Eab~Et0MmD2IhbX(sUTQRNeM|1_=Ks*Ym(mxMRxVNZ01Bqg~D>_Wsl zEE;>%#w2T#p<#o!v(P1tk+&^6k>rnm^S*uSI^>)0Wf%=a`3QMGv+zHhuZGrv-s=VB z=us2*vjvpQtA7cTOdDT1!u+TJwk<%TZSk!p$e%O-YX7L!l^3VWhy!n~MYZO9e7r?D zH7TzvGgGYeN+-?Jd|?XH8W|aflHANo-FyFja%_4r@cm0ubQCC_RPe9KAh|r&@~ytMZxXrsH(Dygul9ckyqvi=Kek?JJZMAl|#S&dG;3`@v3JRH*Vb$ zYf*Gjw8PaDgaH>eZNiHcBos@XFo0Jd748;2A3B+IMpRPQ?vL<-cMe7??=EV*wocv! z%oX}i)zBvg4E;XPD+wA~`%@EgwI4s(%f^h{?#hSYN)S8K*~6@Ux|4|7E_UK?@Cx*a z$r94$&HORZ*ILv-IWz6s9FK@s?r}J#u4mo+oL_*{tx87G%8w$2k^~s$&WC4rxZEl@ z*gED|1{wpGYYth5{C?3kVx%fjL{MLteb(Dsn`1PLs=D%tuU=W+d@%@9+)(M74eG|K zv#NHPo|Q&LW~~pSsE7lvcS5pr+YgL3uC%|eAN15@{*Mn;=0U!oyKoCC=PM=4S@zosaMc>t1zyJaZZ9#u+%JKcmsn_~Cwbc2kj|{GM@tw~2w; zSHGiGp-r3ps~IPgh6_b*E9}z2U$mA2 z{RSpUC;3%aeFZ>=?8L-9{j#ejd^@I_6iVS;_qSYbf2#WSE=Xc1S=l3nrKdN+?_hzD z=)7ajl*GOz{@+n(P=FxvGT2sz?i%;Oeb`u97Zz;UGOc^1XLIPUxwzeB+^j7SE`;+w zUeELSc>d=m34SXI9GiDG)i`a%a|EvvSnrv+LJ8%V_?-O2tB_rdNLucH|B*dCM?LuJ z6{LpL{p~H_iZ~B{3puzOW4-Xl9u|lLYXzxu$=!XM=PI3kashVyxW#SbB+8C+=EweK zn}HTH_=!d~vubOB4juxUgBc@Lb*5G0XE{OEiwg=}cl&{Sw%DWOyZ!F*wdvk39&5%8 z?fg01m8jaFsM2bQ@ud}-PrXPI$pLfZk6$+I{r2lV6BG3di+Ps~_dSe#Y_?>P+1Pzu zziTv4PG^Y*_!WLbXs%*m5a)6>&CJHM>fe%`2pyganNzW%Sj{wm1MTj#w~((>&Ho2?rV z5Ws)%ps`roKDYhIEw1j0)5<<`_H0gdwPI~;ZPcvqI=P4YBzMEvpCo}mu&536OHNKM zJb@Gpw}o|db#>uoDWLtG-QA7=l10tN!WTyOafbT)`$r}w$bf$F`K(@g-8#XXb~w`! zd!qaiPGEo~9YZFQKL~C??kjg0M%Lcj>$Yjv8~e^9kJOR>T(|C!?V;b-mdfD#(X{|_ ze*MokH=%y0aejaL0~Gi-eaN6cbH4-7et`9Zc5-56?q4-Lx!=|vAqD12nQpxP>aJLj z5*Sz%hMzlkjs=Ji1y0WA=jY)mtFNf7?s6UrSdyyhYGW&_6riPufByV=Z36fCRTF`z zts*QeOoZdKb}F?p@#k0U?Cb!zOOJ?%7>0|6mFI4JbD_dH8sWdfNi7MuAqjwfw~)zF z-K%T==4fJCS}QQUfzVLb($a#A{Ks7+ch5{uXO)yl$Hc_sne6k>TxGUi-DN5kr=zEr z18*oREC0&|{QUiMDl4g5R&7!4T`QRkFVCf=rA5t}AXYM?vu361cIf|G*2Rx z&ykA$!_(2xcG$t}F{)FkiNY^3^v^5ovF208#@Oa$*>Lw3HOA)8pRqs_1t?qXDlVa|2R6?$%&)5w1l1dehWWrXJv`e z^zj+!>rK(_E}=}XC#wYh^Zk}>g_}7pDY@yEuCAw>N9&43g~VrB-T_8v*PPx#fmWgM zo@lC2h;L}n)x#o@QMtuNBz92|*Tmf1Q7e0q5iiM|V={xiKRn(<38hqc?K#q2HIi+QoE@?G2 z+g0E*`o%fa$6Z3Vtc>g8Bv*P2re_kJ20I7RWgdicaO_@VtGq?}U_qV<<^xWEo6z08 z*k-87@JUOPcXK`=EpVvC9Y0B^U=F`})k#@^Ur3ifC?ev+Szp0)dtov);L(d|>bX+a z-xe(`X_MU5(Scner`7-CA=D$ju)uj9A8(EAG$ay=B>C9%INrV>uZf}(N=lT1SjOad zOPS^+O!wb^-<_=WXOYjCiD6fo;<$s#$|FhX zZL#VuBq>KmqVbaz1csVbWe(Qgzy34TOpI7wuUZNJC(JRMT6|5EpRAj#Q?mCxvvTHK ziROH*5ndMzsFG-^QdwTfDjXVePffgxU-YrEvORewRwOEtG27jZBFS93blOIY(Y+Lr zhGK2oo?UHC?u9${`_#`Uve{Tl$Xs@aBlE(*_d82)57oq!&x0^yJ^_>D_y-UAI@_XA zTz^eXve2QHHqJY1d<3;~xFlYBkds-bvf{>Q3Z71=Z=tEFap&>JqMW5xP36tSC}Nzt zh>ng!sY%n^?BgTnDl2nc{Li}O=CoT#Idx6@deYoXTL&`q4+&9fL4tQa7S)k$RCkWg zcGl?zF_po57E|m7S!P4BL3ev;{xL?vwu&}>4oV*nro9-GI@&c(XXSU zrG?gdq82+J$CEvh&Kj7TM=CYX(k~U&8CkrH?1K@44fQ!i9OErrqHnzOS>L>b3=_kP z9f+C^;=#DV01h5%+0)Qib`x`Yqn{u5n{7c9bF8FDSD#oznISJ(_x84Hz82`e{AF!bWzGl9F5+iaFg#UMR{Q$9i#EkITEdiV@;g0_l0YQ!0XwoT4HmZY|G~ zNQYEUMP~^8ack{ITgJa7TA`<(GntE7M0u~CSHhZL!V7TyzrKA-l~+oY_VqWbbXf>? zatZ|=(c(2PFHbLz)~1n>R9U6eJ5^P&tP1sNVv(e@;Gq^PaBwRDBkd8Lox?<*S9y44 zr`d52Fyxhy@sXhBn{04{7KTzRNC=c}1}mw0 zxwllC7d3s@xRE!|tmFMR5~JB?@h^3$K7zPkoYsW9CpkA>n)27Z)c0$lTuCV#_>@XL zQVvl)7EWn#DV|j>rvyU`ly107fKAidL7CpIpqS!T;Z8DDRwh2rKBnB!As^@}w$j~h zWMdPHO4zn-7>uFDqcvI6essaqDlhAX|LmW|U*vxu6^}b(A_iU>z0jgl++TXkd~SNU zqPtr&$Q9X$;|%YWu+Vi%O80G&zb$5FE=Da(G(Xz2$0a$fHGgEp8V>aRySkZ;l9FhA z1&wA+>rH8%U+A!r%EkJh&5eo}-UK#~^WJ@|EXio(;Z}7tc;g$LTaK)5a=BsL3pTU3 zw}$(t66@XegHy1Ei?()l%Z;#C?#N!W;^&{@6E`=jjIPdobxLS#f-L^)uTwAzRM4{q zFe+xXhX;?J9;&|Dv;Qt-A*hniv&Z{I&f1w>l*|2o&x!S|Wn~s;Wnv&GM+jWi&=y5I z5mS><5QKP?o~#xrg3Y+t6&BAMH6QgNl;yV1va$zO_V%WkmWu3Ht9q5pBrq`0$oTnc&ip32Fem3YWU>eL@4r=^yW!1+a_3FCkh(&N;^OYE zlV@UCcnz7P+ZrZfaqT@li4zkO)3dWG^p5qP4nr}8FxZj$1{78q!SSv&SAEJ`wn4e$Nw6`xF8*eG5(?6v1BkQ~F{565I;*Unf;tEren>dhl)>nGAHjqe~L0YTt zuZyw>*F`0l`1zkLVUH;)iL6y8U-!TK(%Z^rdnqXTXhK^YA5juLFE3T*OzdZoy^zdXc*Z0(?yj_$X{>XJb=w=8VMa*^KSmkytSa`EZnwP>HRotE<)z+~CSD z;Yzy&E*~SYA z&rySznr9@cI68{ax3{mW@^zUX-G}p^{o4Qnw9(RHv1tKjs^kEE!(@i{1Da`qVh@OrEyVInqiGf6fpo9EKo#W}^HONwIZe}b36DmKWzRCd| zI?x#l9nzXa?d?YrlSsI#s>PZ-QM)hWEs(%0;g(`wy+STmkds?hV@)>j@bK^KV&EYC z4bjwWaGR~qv;$9TTN)H_R!5JSn3vaSDn_iYQyGMqY@Q&2CYmUGnp`{Q#%+TY(89;l zXqz%ByQtFN7oYW4a~|Kh(`hc&<)({9DkC%$k&x@FwDqTvYL7N(|H|V-dUKv>=b=~X z;CLLV3TD50tI)dQyaIepRt9XOSm&4?VRPU_t-A;9mipvmVxS;nL1x0WHaD-Ua3HDF zvtU%#b@rN&vPr(K^K=y(%gMpaf4ofIe}L3jU0r5xo8|h#WM9P6WNRzdFDwkEY?L6| z(P1H@oQ70E%;z|*m72)}JQ5BD4++?uoS4GM!z8B4or+G_wu7l$V8$*p8iSh>uQ%c( zQj#pZLzadey7l##oo#JM9XLHPGq~n-;~^Tsd`?&o^k$+fq6n2=NXcQn$OL#{=i;y z^r}}r`Eqbf79E;fAC_^r1Fl`^K3J!Jn7`j`h7426eLgUt)I(1lFN90tlI*Tc!8aZ^tk8r(DZ{=lWBw)9qd>9cDL z)^^Sz><+HhGgZjX!cKaL6PA!vGJ3E0)cFT53QRDhnw?XeoSa2&QydbEb@AFzsljr` zpeTn9kRaHZQIr)te9QT-UUGAt%602DF+U+7BO4dc7hp$sbb@+uI7wJ|>$}ci$RX-V zeO>BaIiMY5Qf1E|#tOeW2;k<_z3%SORJ%Gi>fJ6mq_#4)u{ls&q#Hm?JvTjLTG?%- zDxXvq{$Q#IE1twGC^DJih2ojso_zqeOiV)~CnzMO>*_2Notx%VLk7-b16pefqKR@d zv(rX#bRrp9Foap-)P>ap5|w4Op~kJ zofoUT%Vk+k$l}$}_dY)3P(CdL1?FeOC_5b%_^1y6LqdN)&CD)ih)(aLcgKe%2P_+n zjl0uXcTGe|hMWOY?1X^wC&)U8H%a(Wuh7G;gch=t+`SQT?Rb-ohWOxm0)amUuUwF_ z>OyY|Q?L0QH!Kx#_Kk1eN^Y`HmJV!*)q-tO`^gf&ts7)W6NPDWl|$IxYCUfbjl!WMmWTcu0_{y$m)9~Y-;`KJlt-6hY^U>zO!9lP1wJk=)K~brq@Pwv&mootXrc8I6F+X= zvxn$RE#3y{;vhU-D-O5mnyc4bt6~Cc{oJIVM_1o#x7jUM78WK3s0YR3B%~8MDl@ut z_GE|XOC-}+O<@RbGBpZ4U)pkC8Ii#K%HQy`s)8Jx)=5wH^7qXO^Q?&rYUSRj_E-M+ zTV5!Ok9Uf^wh-^BN9=xWH#bj!5YiPE+SZdOR8c;>QQXXA68c*Cz}X=2hQGh6zQRi; zjaE($VNEnNEPhLtIqVQx;yKq{_1==NUC zsK4}6lBYVLrUh#QHzyAj1VAO(_RRY3!vr#!*}lBINE_{1D102qG1R=#Y->edOM;^W zAQaK9#%kguIh8lu0K5gvNpY4QUsM5=xP)t!i54W*d@WW2)A}ABUALxa#l;AoSO8w^ z`?qhXpo$w~q6u>cHOYefO;9P%63-4+5V5meRHrJF{B&(!h*F;PF`ef*G@4S-K$7ZQ z7j?3%^h@-uzQ5GdPgfRMmpY9Rd!bsia&yx{r^~6NQ|D%~-K5?Xr6lL$R0kiBNFf7i zqFc=s;p5B9CtbEHmF6E|~k^%N%*W8}jsLV^OB zX3XFcf~?S&5t6fq2f4qtdcJQaCWiYYPFzNQt`Dae5j|<8Cd|-58wg*8MMamM@t8@^P`eo536MG;g&hGbwy)1xx$H%z z5$RyS_(v}s&TMkU%Z)k_p!frd`xYD*i;Kyh0kq>ckl zRXa0-;HGh$ijWkkILuP%4K$u)B&zD}Ldx^G_8IJfIIFrM10TynS=dTR%ROOD&TXo!GeXDwQf}xmX*a) z!50M#oOM~T3kaAyuH_?laD6R4oO zO>X`ZEj)pN_;K1it19CFRjRRz58S~&i zg*Zn$gHsM3^b4P(++ zYf0ffEauVka_Fk$b*v8d!<#pE%R;0q>IY_5p@wyD>>I8T`VXy6*Shcr|Hb*43H{|Y zHAY+d8a}cvJpO0zz5m$p+kd_`lypS}zTIm36l8=qKsQT+9u+EK^`LGqgLWo0p9Lnb zw75uoB?r{Wq@49=(o~&qQ#*Tn%`}G^wEoX$xqnuv$r?C0Il+-2pLIbVt< zB1BJlrnB(MtD_)PAXDpR3BkagFCHj!rFOfGxssV+_X1_U@k-vI82epoU{=#B$( zJYc2{pp2RkU9TqSs{-kK<|ax{kRWu!X@-V-EQkIoKU!T#P#s9SS)YYgZXFj9i9rGV z+>|W$Iie5KEXnO-sU%d^vOS2Pl)<#hQnLsISrE@#q%ZWba&mJ;mX?-?pK*#95DhRB z>KAX|vobS}R1O5XftDjy92$*KLo?QltRAPf?~%$>bkt%~5F9yks9{S~XoKM)7N_+q z506hfT7;1mqt^t&a8P$uiDes!f)L+NPhlhrMe6Cf0#kx0Rfiwx2Va+Z{ps#DUS21V zx^i2bRAP#Yi%~)E+J$yuDxu!yJ%U!hV6r?UkK0txrfrp!baNmO9YO1HmF1Ln$ z5hG(`+Sx`V5JE?T=7U8Iie7s>p0}`{`i>=7<3T&&2m*3>P`@CPJk0hA3&IcIkPfoI zfoS9q$g;c6PcK08KP=fNJqC2sXGrNgegAnRavW{TNv38M6o|vIFZTVxfv3>nLRe_| zYj{U@cg58f5mhb;zS?lE?v(Vcw_7pLAi^OySV<(+`=af9dxF&K6|{b!Q5`33au_5# z6!ZG|E(xoOLjH#X%d?d%EG5X644q)$nR3g@WT1tTBi_kijX&sB^Qx*{1g)pS9Y&EpeYz?|9LA&2c_vK&!1MF-W9TlqjH6!i8Rf;!&d#QSHV)floNg3y$<56TKV06Sr;T6iA6&8t{d!Lbg;tp(QW*E8gRdbI zGP=YY9N_DFMn8z&F;$+cEQxL(9L#u*QxMY!Eu0U??#w5V_751y;3fB$SWD#=7fU7B zg<|!TzWwD+FBb(#h;2hdLuhN|S`qtFZifXb@C1m`<4_j$p&{KRcJ-7&kavC2(bs1| zbI6uWeAZ7v^mGjEr&wu}XHpvgz#iI2dC!;ipf|MH`ABeo8JIcrtNRw(nCb;_X{$;U z4#(Um^!>c;(x|x;qbZIEof3S$ec;QrmSQy=^lCx&_&A*~cBFm{5xuQ8^zyMfFenfc zulssm`jEF3{QSfm5zBV7OVP^4&(+PFTzXf)s2pX|0CP_aOgDf@3)tSIKu=5^7>>ni$`tp3%fi5bQ)#?@MQ(QHgZw>9+ zcokUCD&ZW`W~OeY$m_N09CyIPDGY6vG9Yc3sNg;nYrsNblLR zXJmZ5g|Ghmhv~VwTv&VHwV?5U{fnZFppc0Q9v0@(D53GZAz##nkN}_a@Acq*;!#Z;7#N5EGlHHWl zXr)49x|&hbk|tM;Z2HjOEZ`k+uM9{ev%mpuv*-_4c zc`HFnAj)Qu)Tbb)tXb%vBu9lUejZgu+SW;dnmyI~-Da?}kLT*St-=57$@n-uJ$-#e zebpjw3zLt?$9#btS$Oh5xNAtR=xW)IXi)x_v#3OgMx3|ZBdtz7fsSaYvDbV1O#>d@P zcQP~-LJnZ?t41tY*@vfu&X&<&>cFw!7Q5`0Ol_y1@d-T6SVQ%^|FW27Gx12!JJ;u} zhPWx~u%(%G6GDlrRUOP&_5L2P+lN0oO*qVM5+GLrpgg|&fx3{%L*VWY_t`cm& z1)^0grN__DPc)&KXF+7G@;=nI0}UW>{ZemzD`;i8A>?C_p74$=z&bBj2YsprzrgLx zRf&rV8Mm)rR$u$pmQPRJ&n2KhZ|8-?w_(uam)JswxK1*o%g&v0-u;v z3!MeoG$$3)B+BDgx8_NrZiGwhK1M?PkJ6Y0#nPLtZ8vWO8s!?LNi3xqB(hUE=W0cZ zfP_JZ`%W7$6sIffhWaSPs(~%ZMA9oDBO&Swh?#2qKlBnba5u{5~W|GPD}# zIPJrU q0JX-peDn0jVdno_WZnPGoNRL5gtn_nSA37mDq2HNz4+qQyZ;MAa{q?_ diff --git a/Erros/holerite_carlos-matheus-pimentel_10-1.pdf b/Erros/holerite_carlos-matheus-pimentel_10-1.pdf deleted file mode 100644 index 2e16bc1..0000000 --- a/Erros/holerite_carlos-matheus-pimentel_10-1.pdf +++ /dev/null @@ -1,2280 +0,0 @@ -%PDF-1.3 -%ÿÿÿÿ -1 0 obj -<< /Creator -/Producer ->> -endobj -2 0 obj -<< /Pages 3 0 R -/Type /Catalog ->> -endobj -3 0 obj -<< /Count 1 -/Kids [5 0 R] -/Type /Pages ->> -endobj -4 0 obj -<< /Length 13502 ->> -stream -q -/DeviceRGB cs -0.03922 0.03922 0.03922 scn -0.0 771.89 595.28 70.0 re -f -0.97647 0.45098 0.08627 scn -0.0 771.89 8.0 70.0 re -f -1.0 1.0 1.0 scn - -BT -50.0 806.71 Td -/F2.0 20 Tf -[<5245454d205452414e53504f52> 44.92188 <5445>] TJ -ET - -0.97647 0.45098 0.08627 scn - -BT -50.0 790.3 Td -/F1.0 10 Tf -[<53697374656d6120646520436f6e7472> 21.97266 <6f6c6520646520437573746f73204c> 17.57812 <6f6792737469636f73>] TJ -ET - -0.0 0.0 0.0 scn -0.97647 0.45098 0.08627 scn - -BT -40.0 723.023 Td -/F2.0 13 Tf -[<484f4c45524954452044452050> 91.79688 <4147414d454e544f20d120454e545245474153>] TJ -ET - -/DeviceRGB CS -0.97647 0.45098 0.08627 SCN -40.0 717.303 m -555.28 717.303 l -S -0.0 0.0 0.0 scn -0.95294 0.95686 0.96471 scn -40.0 689.313 120.0 19.99 re -f -0.0 0.0 0.0 scn -0.95294 0.95686 0.96471 scn -40.0 669.323 120.0 19.99 re -f -0.0 0.0 0.0 scn -0.95294 0.95686 0.96471 scn -40.0 649.333 120.0 19.99 re -f -0.0 0.0 0.0 scn -0.95294 0.95686 0.96471 scn -40.0 629.343 120.0 19.99 re -f -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 709.303 m -160.0 709.303 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 689.313 m -160.0 689.313 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 709.803 m -40.0 688.813 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 709.803 m -160.0 688.813 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 695.073 Td -/F2.0 10 Tf -<4d6f746f7269737461> Tj -ET - -1 w -0.0 0.0 0.0 SCN -160.0 709.303 m -555.28 709.303 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 689.313 m -555.28 689.313 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 709.803 m -160.0 688.813 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 709.803 m -555.28 688.813 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -168.0 695.073 Td -/F1.0 10 Tf -[<4361726c6f73204d6174686575732050> 21.97266 <696d656e74656c>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -40.0 689.313 m -160.0 689.313 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 669.323 m -160.0 669.323 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 689.813 m -40.0 668.823 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 689.813 m -160.0 668.823 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 675.083 Td -/F2.0 10 Tf -<436f6e736f6c6964618d8b6f> Tj -ET - -1 w -0.0 0.0 0.0 SCN -160.0 689.313 m -555.28 689.313 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 669.323 m -555.28 669.323 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 689.813 m -160.0 668.823 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 689.813 m -555.28 668.823 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -168.0 675.083 Td -/F1.0 10 Tf -[<5562732053756c204c> 17.57812 <65737465204d6169203230323620204361726c6f73>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -40.0 669.323 m -160.0 669.323 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 649.333 m -160.0 649.333 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 669.823 m -40.0 648.833 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 669.823 m -160.0 648.833 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 655.093 Td -/F2.0 10 Tf -<506572926f646f> Tj -ET - -1 w -0.0 0.0 0.0 SCN -160.0 669.323 m -555.28 669.323 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 649.333 m -555.28 649.333 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 669.823 m -160.0 648.833 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 669.823 m -555.28 648.833 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -168.0 655.093 Td -/F1.0 10 Tf -<30312f30352f3230323620612032392f30352f32303236> Tj -ET - -1 w -0.0 0.0 0.0 SCN -40.0 649.333 m -160.0 649.333 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 629.343 m -160.0 629.343 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 649.833 m -40.0 628.843 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 649.833 m -160.0 628.843 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 635.103 Td -/F2.0 10 Tf -<456d6973738b6f> Tj -ET - -1 w -0.0 0.0 0.0 SCN -160.0 649.333 m -555.28 649.333 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 629.343 m -555.28 629.343 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -160.0 649.833 m -160.0 628.843 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 649.833 m -555.28 628.843 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -168.0 635.103 Td -/F1.0 10 Tf -<31382f30362f32303236> Tj -ET - -0.13333 0.77255 0.36863 scn -45.0 619.343 m -285.0 619.343 l -287.76142 619.343 290.0 617.10442 290.0 614.343 c -290.0 598.343 l -290.0 595.58158 287.76142 593.343 285.0 593.343 c -45.0 593.343 l -42.23858 593.343 40.0 595.58158 40.0 598.343 c -40.0 614.343 l -40.0 617.10442 42.23858 619.343 45.0 619.343 c -h -f -1.0 1.0 1.0 scn - -BT -52.0 603.753 Td -/F2.1 10 Tf -<2120> Tj -/F2.0 10 Tf -[<50> 91.79688 <41474f20656d2031372f30362f3230323620e120506978>] TJ -ET - -0.0 0.0 0.0 scn -0.97647 0.45098 0.08627 scn - -BT -40.0 563.476 Td -/F2.0 13 Tf -[<50524f> 26.85547 <56454e544f53204520444553434f4e544f53>] TJ -ET - -0.97647 0.45098 0.08627 SCN -40.0 557.756 m -555.28 557.756 l -S -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -40.0 529.766 150.81161 19.99 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -190.81161 529.766 100.23675 19.99 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -291.04837 529.766 121.60214 19.99 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -412.65051 529.766 142.62949 19.99 re -f -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 549.756 m -190.81161 549.756 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 529.766 m -190.81161 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 550.256 m -40.0 529.266 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 550.256 m -190.81161 529.266 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -48.0 535.526 Td -/F2.0 10 Tf -<4465736372698d8b6f> Tj -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -190.81161 549.756 m -291.04837 549.756 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 529.766 m -291.04837 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 550.256 m -190.81161 529.266 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 550.256 m -291.04837 529.266 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -263.61837 535.526 Td -/F2.0 10 Tf -<517464> Tj -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -291.04837 549.756 m -412.65051 549.756 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 529.766 m -412.65051 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 550.256 m -291.04837 529.266 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 550.256 m -412.65051 529.266 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -378.43051 535.526 Td -/F2.0 10 Tf -<556e69742e> Tj -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -412.65051 549.756 m -555.28 549.756 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 529.766 m -555.28 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 550.256 m -412.65051 529.266 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 550.256 m -555.28 529.266 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -519.13688 535.526 Td -/F2.0 10 Tf -[<56> 54.6875 <616c6f72>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 529.766 m -190.81161 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 509.776 m -190.81161 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 530.266 m -40.0 509.276 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 530.266 m -190.81161 509.276 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 515.536 Td -/F1.0 10 Tf -[<456e7472> 21.97266 <656761204e6f72> 17.57812 <6d616c>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -190.81161 529.766 m -291.04837 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 509.776 m -291.04837 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 530.266 m -190.81161 509.276 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 530.266 m -291.04837 509.276 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -264.96837 515.536 Td -/F1.0 10 Tf -<313036> Tj -ET - -1 w -0.0 0.0 0.0 SCN -291.04837 529.766 m -412.65051 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 509.776 m -412.65051 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 530.266 m -291.04837 509.276 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 530.266 m -412.65051 509.276 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -360.57051 515.536 Td -/F1.0 10 Tf -<52242031352c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -412.65051 529.766 m -555.28 529.766 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 509.776 m -555.28 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 530.266 m -412.65051 509.276 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 530.266 m -555.28 509.276 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -478.94 515.536 Td -/F1.0 10 Tf -<2b20522420313539302c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -40.0 509.776 m -190.81161 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 489.786 m -190.81161 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 510.276 m -40.0 489.286 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 510.276 m -190.81161 489.286 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 495.546 Td -/F1.0 10 Tf -[<52> 44.92188 <65746972616461>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -190.81161 509.776 m -291.04837 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 489.786 m -291.04837 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 510.276 m -190.81161 489.286 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 510.276 m -291.04837 489.286 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -271.32837 495.546 Td -/F1.0 10 Tf -<3131> Tj -ET - -1 w -0.0 0.0 0.0 SCN -291.04837 509.776 m -412.65051 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 489.786 m -412.65051 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 510.276 m -291.04837 489.286 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 510.276 m -412.65051 489.286 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -360.57051 495.546 Td -/F1.0 10 Tf -<52242032302c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -412.65051 509.776 m -555.28 509.776 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 489.786 m -555.28 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 510.276 m -412.65051 489.286 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 510.276 m -555.28 489.286 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -485.3 495.546 Td -/F1.0 10 Tf -<2b205224203232302c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -40.0 489.786 m -190.81161 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 469.796 m -190.81161 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 490.286 m -40.0 469.296 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 490.286 m -190.81161 469.296 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 475.556 Td -/F1.0 10 Tf -<42996e7573> Tj -ET - -1 w -0.0 0.0 0.0 SCN -190.81161 489.786 m -291.04837 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 469.796 m -291.04837 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 490.286 m -190.81161 469.296 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 490.286 m -291.04837 469.296 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -277.68837 475.556 Td -/F1.0 10 Tf -<34> Tj -ET - -1 w -0.0 0.0 0.0 SCN -291.04837 489.786 m -412.65051 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 469.796 m -412.65051 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 490.286 m -291.04837 469.296 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 490.286 m -412.65051 469.296 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -366.93051 475.556 Td -/F1.0 10 Tf -<522420352c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -412.65051 489.786 m -555.28 489.786 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 469.796 m -555.28 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 490.286 m -412.65051 469.296 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 490.286 m -555.28 469.296 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -491.66 475.556 Td -/F1.0 10 Tf -<2b2052242032302c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -40.0 469.796 m -190.81161 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 449.806 m -190.81161 449.806 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 470.296 m -40.0 449.306 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 470.296 m -190.81161 449.306 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 455.566 Td -/F1.0 10 Tf -<446573636f6e746f> Tj -ET - -1 w -0.0 0.0 0.0 SCN -190.81161 469.796 m -291.04837 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 449.806 m -291.04837 449.806 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -190.81161 470.296 m -190.81161 449.306 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 470.296 m -291.04837 449.306 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -271.32837 455.566 Td -/F1.0 10 Tf -<3131> Tj -ET - -1 w -0.0 0.0 0.0 SCN -291.04837 469.796 m -412.65051 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 449.806 m -412.65051 449.806 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -291.04837 470.296 m -291.04837 449.306 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 470.296 m -412.65051 449.306 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -360.57051 455.566 Td -/F1.0 10 Tf -<52242031352c3030> Tj -ET - -1 w -0.0 0.0 0.0 SCN -412.65051 469.796 m -555.28 469.796 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 449.806 m -555.28 449.806 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -412.65051 470.296 m -412.65051 449.306 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 470.296 m -555.28 449.306 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -490.07 455.566 Td -/F1.0 10 Tf -<2d205224203136352c3030> Tj -ET - -0.97647 0.45098 0.08627 scn -48.0 437.806 m -547.28 437.806 l -551.69828 437.806 555.28 434.22428 555.28 429.806 c -555.28 395.806 l -555.28 391.38772 551.69828 387.806 547.28 387.806 c -48.0 387.806 l -43.58172 387.806 40.0 391.38772 40.0 395.806 c -40.0 429.806 l -40.0 434.22428 43.58172 437.806 48.0 437.806 c -h -f -0.0 0.0 0.0 scn - -BT -56.0 420.216 Td -/F2.0 10 Tf -[<56> 67.87109 <414c> 35.64453 <4f52204cea515549444f20412052454345424552>] TJ -ET - - -BT -56.0 397.108 Td -/F2.0 22 Tf -<522420313636352c3030> Tj -ET - -0.41961 0.44706 0.50196 scn - -BT -40.0 374.975 Td -/F1.0 9 Tf -[<54> 169.92188 <6f74616c20646520656e7472> 21.97266 <656761733a20313332>] TJ -ET - - -BT -40.0 364.184 Td -/F1.0 9 Tf -[<56> 77.63672 <659263756c6f73206174656e6469646f733a20474144455f3030362c20474144455f3031312c20474144455f3035312c20474144455f303434>] TJ -ET - -0.0 0.0 0.0 scn -0.0 0.0 0.0 SCN -0.7 w -40.0 320.224 m -277.64 320.224 l -S -317.64 320.224 m -555.28 320.224 l -S -0.0 0.0 0.0 scn - -BT -95.532 305.393 Td -/F2.0 9 Tf -<4361726c6f73204d6174686575732050696d656e74656c> Tj -ET - - -BT -393.20988 305.393 Td -/F2.0 9 Tf -[<5265656d2054> 109.86328 <72616e73706f727465>] TJ -ET - -0.41961 0.44706 0.50196 scn - -BT -111.376 292.152 Td -/F1.0 8 Tf -<417373696e617475726120646f204d6f746f7269737461> Tj -ET - - -BT -379.55831 292.152 Td -/F1.0 8 Tf -[<417373696e617475726120646f2041> 17.57812 <646d696e6973747261646f72>] TJ -ET - -0.0 0.0 0.0 scn -/Stamp1 Do -Q - -endstream -endobj -5 0 obj -<< /ArtBox [0 0 595.28 841.89] -/BleedBox [0 0 595.28 841.89] -/Contents 4 0 R -/CropBox [0 0 595.28 841.89] -/MediaBox [0 0 595.28 841.89] -/Parent 3 0 R -/Resources << /Font << /F1.0 7 0 R -/F2.0 6 0 R -/F2.1 8 0 R ->> -/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] -/XObject << /Stamp1 9 0 R ->> ->> -/TrimBox [0 0 595.28 841.89] -/Type /Page ->> -endobj -6 0 obj -<< /BaseFont /80f3e1+DejaVuSans-Bold -/FirstChar 32 -/FontDescriptor 11 0 R -/LastChar 234 -/Subtype /TrueType -/ToUnicode 12 0 R -/Type /Font -/Widths 13 0 R ->> -endobj -7 0 obj -<< /BaseFont /916dd0+DejaVuSans -/FirstChar 32 -/FontDescriptor 15 0 R -/LastChar 225 -/Subtype /TrueType -/ToUnicode 16 0 R -/Type /Font -/Widths 17 0 R ->> -endobj -8 0 obj -<< /BaseFont /1a10e1+DejaVuSans-Bold -/FirstChar 32 -/FontDescriptor 19 0 R -/LastChar 33 -/Subtype /TrueType -/ToUnicode 20 0 R -/Type /Font -/Widths 21 0 R ->> -endobj -9 0 obj -<< /BBox [0 0 595.28 841.89] -/Length 369 -/Resources << /Font << /F1.0 7 0 R ->> ->> -/Subtype /Form -/Type /XObject ->> -stream -q -/DeviceRGB cs -0.0 0.0 0.0 scn -/DeviceRGB CS -0.0 0.0 0.0 SCN -0.7 w -0 J -0 j -[] 0 d -/DeviceRGB cs -0.41961 0.44706 0.50196 scn - -BT -155.96989 29.928 Td -/F1.0 8 Tf -[<47657261646f20656d2031382f30362f323032362032303a323320e12052> 44.92188 <65656d2054> 146.97266 <72616e73706f72746520d120646f63756d656e746f20696e746572> 17.57812 <6e6f>] TJ -ET - -/DeviceRGB cs -0.0 0.0 0.0 scn -Q - -endstream -endobj -10 0 obj -<< /Length 27924 -/Length1 27924 ->> -stream -€`OS/2k!’ÙhVcmap(cêšcvt >č1 Tfpgm[kđŒŹgaspm glyf!™yÜ €head&LKèì6hhea ‘S$$hmtx&kÀèlocaö^îŸdvmaxpEÆH nameńł./€<ëpostl ‚Ollœprep|aąçh§^žà‰_<őàúŃ9àúŃ9ęmțVŽkmțVęmÿ„Ž::N@í6Œ3™3™Śf PfEd êțšmă ÍfÉ‘  -m -Ńì‘b‘瑹‘ž‘‘‰1 -Œßf€ŒwŒ‘fČŒúŒŒöŒČŒÍfĘŒÍf)ŒÓu -Œ1 -fXŸXș\mXČŹŸŹŸŹVȘČŹXșŹòŹĂjÓČ )fXŸXŸŹn -ŃúŒęm€ŸŹm  -  !"#$%&'()*+,-./01234·, °%Id°@QX ÈY!-,°%Id°@QX ÈY!-,  °P° y žÿÿPXY°°%°%#á °P° y žÿÿPXY°°%á-,KPX ž(EDY!-,°%E`D-,KSX°%°%EDY!!-,ED-,°%°%I°%°%I`° ch ŠŠ#:Še:-A„€&ț%$!:$ú#"!:"ț!: ú»dțțțțțțțțț}ț}  Œ ț À  Y Œ €  -& Y @ -& țț €ȗ.Aúúțúú@ÿ}ÿ>țțüû,üțû,úțùűGù}űGśúöțőțôțó»òțńțđțïîțíì -íțì -ì@ëê -ë2ê -éúè‘èțçúæúć‘ćțäțățâțáțàțßțȚúĘÜĘdÜÛ ÛdÚÙ%ÚúÙ%ŰŃ%ŰúŚÖŚÖŐÖŐÔÓ Ô Ó ÒŃ%ÒúёŃ%Д Đ#ÏÎÏ&ÎÍÎÍ̑ÌËÊÉ»ÊțÉÈ]É»ɀÈ@ÿÇ%È]È@Ç%ÆțĆdĐÄțĂÂțÁțÀż:Àúż­ż:ŸœŸ2œŒœŒ»Œ»ș »ș č‘čțžț·¶”țŽțłțȱ°Ż­Żúź­źú­‘­Ź‘Ź}«țȘ&©țšț§țŠț„ -€țŁąŁțąą@Ą Ąú ‘ Ÿ‘Ÿúž” žțœ›»œț›š]›»›€š%š]š@™ț˜—.˜ț—.–‘–@ÿ•” • ” “‘“K’‘’ț‘‘%ŽțțŒț‹țŠț‰țˆ‡%ˆț‡%†ț…ț„2ƒ–‚țț€ -~ț}ț|ț{úzúyțwvŠwțvŠutuútsúr}qțpo,o,númúlúkțjțițhc h2gțf2ed -ețd -d@cb -c b -a`a–``_ -^ț]ț\\ț[Z[țZZYțXúWțV@ÿVțUțTSRQRúQQPOPúONONMLKLKJKJIJIHúGFGFEúDCDCBA%BúAA%@?@ț?>?>=< =< ;d:ț98ț7656%5455À4 - 44€32 33@2 10Š1ț00Š/ .-,:-ú,%,:+d*d)ț(''& %$#@+$#" "ú!!@  -%ž@‘ K}Kț%ú%dțțdÀ€  ú 2 -   € -  -@ țțț -ț -@údț  žd…++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++f3fŒé=ąúffŹTìŒbf…Tfm€fÍ3bq%€ŒșćfHZfmöĂđ99Xm=ȁČfuf°f9Ńœ{Ï{X3fLfLʚJ#ššDDÍÁf?š;ËËŐŐPŹŹw -Çò/XČ#öö/55îç3˜ŃX -š˜ŒÍććòsfŐ+ŐĂáŚćj-ŐŐđšjìáŐ!fű샊ű#^Í`ljìŒș3B3\Őššáfy```{ìĂžÍŸĘŐj\{šĘźș…ź`bššXîššŃÍšPËˋ‹1öđL`šÁ%Á!J–Jƒš7{ÉÁÁÁÁ–'žì}3˜ŃXyÍ9bœœœ“ž“žs&&&°Îäü>lȚ.ˆŸ\ŒL~Ű6RŰ0zž”(V’ê t Ÿ - -b -Š -Đ -î ^ ą è 6 r  x ÀœźșÌäü:Î -(Rfț–f€Œ&&¶‰/ÄÔì1ÔìÔì0!%!!füsüćț–űòr) țÓ#*1s@? -%$ + ,#–•,–•“,“ (/($ +/ 2ÔÄüÔ<ü<ÔüÄ991/ÄìÔÄüôìőî99999990#.''.546?3.'>54&ą}êosëy!ïÉőăądÈedÈe țÍôśąGUNđWWPțÓ-.);?7*Ž©łÉ çă"ț*/țá(»·žĆBE5;Cț±țêBBDCmțĘ9ƒ@ ©šüìÔÌ1üì0!#ŃhśŐdƒțÏț‹uŃ9ƒ·šüì1/ì0!!Ńhț˜ƒț}ÿBìŐ·/Ä991ôÌ03#ȚęńĘŐùmbÿă/đ #@ ŹŹœ˜ üìüì1äôìî0&#"326! ! źi||jj|{jțÀțÚțÙțÀ@'&@ìććțèțćèèțțm“st“țmçŐ -(@źźź  ÔÄìÄüì1/ì2ôìÔì0!%!!!đTțŁ[nTûì -ĆHHû5țöąßđ‹@)% ŹœŻÜK° TXčÿÀ8YÄüÔì9991/ì2ôìÔì990KSXí9íY"@&**"""555BJFF]]!!>54&#">3 N‘ûĂ!IFuZÖz‚țz )~ÊțćáB~Di€MLH+-ìÓzÓ±žÿăŐ=@"•Ź–•ŹČ Ż ˜" -ÜÄüìÄî1äôìæțőîțä90!!>3 !"&'32654&#"Ùœęv,Y00ț”țÚù{zÛaŒĄĄŒSŒlŐțćç țïôòțî12/FF‰uvˆ+-ÿă#î $7@ŹŹ – •Ź"œ˜% %$%üìüäîÄ1äôüôìîÖî90"32654&.#">32! !2ćeeeefeev_šPŹÀBš[ćțÆțűțĘțÁuEgÂჃƒƒƒƒƒƒÍțì-+żŒ11țôÙđț߉ir§ ‰îŐE@%ŻÜÌ91/ôì0KSXííY"Č]@ &5F]!!!‰eęșț‰'ę1ŐÙûș -'Ő -ț@@ - -  - -% -ź -   ÔČ]Ä91/<äÔì90KSXííííííííY"@€ -/ -V -f - t -Š -Ÿ -ż -ż -Ï -Ï -ß -  %* IFGH XYVWhifg` t{zu{t ‰††‰ ™–•š ¶č ËĆĆËÂÍ ÙÖÖÙŐÚ /]]!!!!!FęŠ_ț})Ë)ț}ęš™ÌțđŐú+%RŒ‰Ő P@%čŸč č   !üì2ÔìÔì99991/ìôìôì90@ ""/"P"]2654&+2654&+)! [^^[ŐâtutuâH|ˆțÜțÖęB7f“PNMQțÄęsbcaațy$ŰÔŐŒÏm™fÿă\đ;@   źź œ˜- +üÄ2ì1äôìțÄĆ990Ž/_]%# !2.#"3267\jæ}ț‹țLŽu}æjkĐsÎììÎsĐkR78ĄefĄ87țËIDțűèçțűDIŒ9Ő.@À À --. üìüì99991/ìôì0ČP]32654&#! )=Šìùűíęő–TMwiffixț°ț°țjČüqêßȚè#atețű§©țśetaŒáŐ 0@ÀŸÀÀ -  üì2ÔÄÄ1/ìôìôì0¶ P p ]!!!!!!ŒęrgꙀûÛŐțĘțêțĘțȘțĘfÿăúđK@%   čźź œ˜1 3/-+üìôäüÄ1ÄäôìîîĆ9990Č_]%# !2.#"3267#!úțÊ„ț‹țLŒ‚•y}ś|æùđĘ@ÀŸ -  üì2Ôì21/<ô<ôì0@P ` p Ÿ -]!!!!!!Œ8țęÈțŐęÇ9ú+yę‡Œ=Ő,·ÁüK°TK°T[Xč@8Yì1/ì0¶@P]!!ŒțŐú+ŒáŐ@ ÀüÄì1/äì0!!!Œ€ûÛŐûNțĘŒ9Ő Î@3666 - 6 - - % -Á   -1 -1 üìÔì91/<ì2Ä90KSXííííY"Č]@f    -+?HOLL -WYY -hoo -• ©°° -*%:5O@GVY Phgej `…Š —]]! !!#!ŒêTVéț”țšôțšț“Őüáú+DüÛ%ûŒŒöŐ |@66%Á11 -üìÔì991/<ì2990KSXííY"Č]@4 - 8GJVYP gh` ș¶>3IO@UZfi ]]!!!!ŒźmțRęáț“Őüú+üfÿăfđ 2@źź œ˜ -7-+üìüì1äôìî0@ /?]"3254 ! f°Â°±Â±h˜țhț˜ț™țg™Ùțüìëțüëìțdț•ț–țdœjkœŒ‰Ő -1@ ź ź  - üì2Ôì99991/ôìÔì0Č]! !#!32654&#Œ1țÏțățțŐpzzpŐęêëęęúŸț_mddlfțŐfđb@ źźœ˜ - - -7-+üìüì9991Ääôìî90@,  '/V S f ` w w p  Y Y YXj i x ]]# ! !"3254țțf™gk•ŚÊ-ț‘ță°ÂŸŽ±Â˜lkœțhț‘üț”\ț°țüìđÿëìŒŐ‡@2% -ź ź  -   üì2ÔÄì9991/<ôìÔì9990KSXíí9Y"Č]@66EEVVPee`]2654&+!! !.#ßyiiyąțL'O}@Ńțf¶7q^?ZgfXțțöęËŐÆÖ”Ÿ-țXspR“ÿă-đ'§@*% - ĂĂźź%œ˜( - "(ÜìÄÔìÄ99991äôìțćć9990@Tp)9999 JJJ X -]\^^ Z!joooh o n!t t t || |!– — ›šœ š!Š Š Š ȘȘȘȘ Ș!(]].#"!"$'32654&/.54$!2Ë{êhŠ„Yu€ùÒțÛțӎț⏏ |~†[ˆ•àÏ {ŠțÄ78LP3 ąpq[QeŠiț—HށźÙ"ӆŽsÆUsèt/ űLJDM‘m)‡ęŠf]˹ƞUO..ïXÿă5{7@ÌÔÌ Ô -ĄĄÊ˜B ;üÄ2ì1äôìțôîőî0Ž_].#"3267# !25I“O–§§–T—@T­WțŃțȘV/X«=țÜ20ŻŻ21țÛ77\ÿă8@ĄĐĄĐ˜ÊŁ @B ;üìôì221/ìäôäìäî0ŽO`]!!5#"322654&#"Šhț˜JČuÏțö -ÏtłąsyysryyŒXùìąc\II]üÉš  šš  šXÿă -{C@!Ű ÌÔŸ ŚŸÊ ˜ D ;üìôìÄ91äôìäțôîî90Ž/?]!3267# ! 4&#" -ü» œŒqí}țțĐțŻK"=țw`h‚3f~~CDțì015:ț“f}unŹ5@  -ÛĐÊŁ G üì2ôì1/<ìôäì9990Ž`€]!54&'.#"!!>32ț˜ H.p€țšfQ¶nÂÉȘęVo™“n#'­™ęÙęšb]îŹ)@ĘłŁ ü<ì21/ìôì0@ P ` p € ]!!!!Źfțšfțš`û țÜŹ·Ł üì1/ì0@ P`p€]!!ŹfțšùìȘŽ{%i@)  Û Đ#Êł  H H &üK°TXč@8Yü<üìüì991/<<äô<äì29990@'0'P'p'€''Ż']>32!>54&#"!4&#"!!>32șD»pÁÊț˜FNfoț˜@Rgpț˜hB«gtČŠhmîăęVH wkšŸęÚHșk©ęÙ`€_`pŹ{5@  -ÛĐÊł G üì2ôì1/<äôäì9990Ž`€]!54&'.#"!!>32ț˜ H.p€țšfQ¶nÂÉȘęVo›‘n#'­™ęÙ`€b]îXÿă'{ -@ĄĄ ʘ BLB;üìüì1äôìî0¶7?G]"32654& ! Áw}}wu||u!Eț»țßțȚțčG{«ĄĄ««ĄĄ«țÈțìțìțÈ88ŹțV^{;@ĄĐĄĐʘȚłB @ üì22ôì1äääôäìäî0ŽO`]%!!>32#"&"32654&țšfJŽuÏ -țöÏuŽ€s{{ssyyąęŽ -€b]ț·țęțęț·]7©ŸŸ©š  šŹì{7@ À ”Ê ł - üK°TXčÿÀ8YÄì21/äôäüÄ990.#"!!>32ì/]/Š•țšfEł}*(/±„ęü`žnejÿăb{'Ü@@  6  -6% - ÌÔÌÔŸŸ%ʘ( - SRP"M(üìÄÔìä99991äôìțőîőî90KSXí9í9Y"Č ]@^ - #  ,. . -. . . ) 9; ; -; : : K J -J J H w w șș ș -ș ș ș %  - 7 ?)_) ]].#"!"&'32654&/.54632sÖ_fcKa?Ÿțűțúoí}kátijIm?ïÀôücÚ=țđ0035+. # «łŽ##44:90/ ą„ČŹ€žm@ĄłĄ -   TüK°TK°T[K°T[K°T[Xč@8Y<Äü<ÄÄ991/Äìô<ì2990@??PPP`` ]]!!;!"&5#33qț>\žțÍÔ±ČČžțÂÿț%N7ÿ±ÔÛ> ÿă`;@ ÛĐ˜ł G üìôì291/ä2ôäì9990Ž`€]!3265!!5#"& hG.p€fțšQ”mÂËŽŹp[țí.‡w#&Ź™)û ąb]î -` y@F -   - % ß -  ÔK° -TK°T[K°T[K°T[Xč @8YÄÔÄ91/<ì290KSXííííííííY"@Ú  / 3< CL R\ bl sz €  ——œ Ÿ  Ż °°°ż ż ż ÀÀÏ Ï ĐĐß ß ààï ï śđśÿ 2   - - $++$ -4;;4 -0 DKKD -o †€‰€ -—•š™š– -§°żż° -ÀÏÏÀ -ŚĐßŰßĐ -çàïèïà -ùö:]] !! ! !Çțl{ćè{țlšț…üùț…=#țŽLęßęÁbțžÿÿXÿăĆ9&6șŽ ?0+1ÿÿXțo5{& 7žÿÿŹf&89ÿw@p]1n°’ČčŽȘ/Ì1ôì0!!n$űÜČțțŃ9‰·üì1ÔÌ0!!Ńhț˜‰ț}ÿÿŒČk&5du@@]1ęmîÿNö8”ÔÄ1ÔÄ0K° TXœÿÀ@878Y@ //]!#ț3țăÄöțű€\9É@  - Œ@ Ć€€ -ÔìÔì99991ôìüì9999990K° TK°T[XœÿÀ@878Y@T              ( ]]'&'&#"#4632326=3#"&7/$&‹g]$I)=%$(‹g]$CT%>;ˆ”+@9ˆ”țoË5@ -   ÔÔÌ991/ÔÌÔÌ0K° -TXœÿÀ@878Y!#"&/32654&'Z:7{0f42S!:A+->j/_[ ˜.(R<Ź`·ß üì1/ì0@ P`p€]!!Źfțš`û mîąf7·ÆĆÔÌ1ôì0K° TK°T[XœÿÀ@878YŽ]!#‡țÄfțˆ2˜ ˜Ł§· ÇÓé ú 4Ž èó 0ś ' =  E  e … " :ż %:ù h;3 ;› ;±Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. -DejaVu changes are in public domain -DejaVu SansBoldDejaVu Sans BoldDejaVu Sans BoldVersion 2.3780f3e1+DejaVuSans-BoldDejaVu fonts teamhttp://dejavu.sourceforge.netFonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below) - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr.http://dejavu.sourceforge.net/wiki/index.php/LicenseDejaVu SansBoldCopyright (c) 2003 by Bitstream, Inc. All Rights Reserved. -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. -DejaVu changes are in public domain -DejaVu SansBoldDejaVu Sans BoldDejaVu Sans BoldVersion 2.37DejaVu fonts teamhttp://dejavu.sourceforge.netFonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below) - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr.http://dejavu.sourceforge.net/wiki/index.php/LicenseDejaVu SansBoldÿŰZ:$%&'(*+,/0123456789DFGHKLOPQRSUVWX[motłĂÌÙȚŚAcuteÿÿ -endstream -endobj -11 0 obj -<< /Ascent 759 -/CapHeight 759 -/Descent -240 -/Flags 4 -/FontBBox [-1069 -415 1975 1175] -/FontFile2 10 0 R -/FontName /80f3e1+DejaVuSans-Bold -/ItalicAngle 0 -/StemV 0 -/Type /FontDescriptor -/XHeight 0 ->> -endobj -12 0 obj -<< /Length 634 ->> -stream -/CIDInit /ProcSet findresource begin -12 dict begin -begincmap -/CIDSystemInfo 3 dict dup begin - /Registry (Adobe) def - /Ordering (UCS) def - /Supplement 0 def -end def -/CMapName /Adobe-Identity-UCS def -/CMapType 2 def -1 begincodespacerange -<00> -endcodespacerange -9 beginbfrange -<2E><32><002e> -<35><37><0035> -<41><45><0041> -<47><49><0047> -<4C><56><004c> -<63><65><0063> -<68><69><0068> -<6C><70><006c> -<72><75><0072> -endbfrange -11 beginbfchar -<20><0020> -<24><0024> -<2C><002c> -<61><0061> -<78><0078> -<8B><00e3> -<8D><00e7> -<92><00ed> -<2014> -<00b7> -<00cd> -endbfchar -endcmap -CMapName currentdict /CMap defineresource pop -end -end -endstream -endobj -13 0 obj -[348 0 0 0 695 0 0 0 0 0 0 0 379 0 379 365 695 695 695 0 0 695 695 695 0 0 0 0 0 0 0 0 0 773 762 733 830 683 0 820 836 372 0 0 637 995 836 850 732 850 770 720 682 812 773 0 0 0 0 0 0 0 0 0 0 674 0 592 715 678 0 0 711 342 0 0 342 1041 711 687 715 0 493 595 478 711 0 0 645 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 674 0 592 0 0 0 0 342 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1000 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 379 0 0 0 0 0 0 0 0 372] -endobj -14 0 obj -<< /Length 27148 -/Length1 27148 ->> -stream -€`OS/2iś’hVcmap9\ü€cvt i9 Ìțfpgmq4vjž«gaspj glyf$zk @ dhead(ČJȘì6hheaű!$$hmtx  –ÀälocańÆù° Ìtmaxpl”H nameȚ/,€<Èpostòil”prep;ńdh^žĘyA_<őàúŃ9àúŃ9ÿìțœfmțÿìÿÊœ99M™Íސ3™3™Śf PfEd@ áțšmă Íf‹ȘŽÙ‹žădČ‡á–œdž‹Čđy}É–s)ÉÉ3suÉçÉüÉÓɏɇăÿúÛČyÿìç{șfqqìqqș9Á9ÁËșșćqșJș+o#7ź9ćqd‹Ûs9ÁÁ  -  !"#$%&'()*+,-./012345·, °%Id°@QX ÈY!-,°%Id°@QX ÈY!-,  °P° y žÿÿPXY°°%°%#á °P° y žÿÿPXY°°%á-,KPX °ęEDY!-,°%E`D-,KSX°%°%EDY!!-,ED-,°%°%I°%°%I`° ch ŠŠ#:Še:-ž€@ÿûțúù%ű2ś–öőțôțó%òń–đ%ïŠAïțî–í–ìúëúêțé:èBçțæ2ćäSć–äŠAäSăâ/ăúâ/áțàțß2ȚʖÜțÛÚ}Ù»Űț֊AÖ}ŐÔGŐ}ÔGÓÒÓțÒŃțĐțÏțÎț͖ÌËÌțËÊ2ÉțƅÆĆÄțĂțÂțÁțÀțżțŸțœțŒț»țșč†%čțž·»žț·¶]·»·€¶”%¶]@ÿ¶@”%Žțł–Čț±ț°țŻțźd­Ź«%Źd«Ș«%Ș©ŠA©úšț§țŠț„€țŁąŁ2ąĄd ŠA –Ÿțž žț œ›œd›š›š™ -˜ț—– —ț– •ŠA•–”“”(“’ú‘»‘ț]»€Ž%]@Ž%țŒ‹.Œț‹.І%ŠA‰ˆ ‰ˆ ‡†%‡d†…†%…„țƒ‚ƒț‚ț€țț@ÿ~}}~ț}}|d{T{%zțyțxw v -uțtúsúrúqúpțoțnțl!kțjBjSițh}gBfțețdțcțbța:`ú^ ]ț[țZțYX -YúX -WW2VțUTUBTSSRQJQțP OțNMNțMLțKJKțJIJI IH GțF–E–DțC-CúB»AK@ț?ț>=>=<=<; <@ÿ; :ț9ț878ú76765 65 43 21 2ț1 0/ 0 / .- .- ,2+*%+d*)*%)('%(A'%&% &% $ț#ț"!! dú d BțúBBțdțțțțBț-B}dț  ț   ț - ț-țdț@-ț-țžd…++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++5žËËÁȘœŠžfqË ȅužĂˉ-ËŠđÓȘ‡ËȘJ3ËÙôTޜ99NŽRžçÍ7sÍ`s3ąVŠV9ĆÉžßsșé3ŒDßÍȘćȘˏ€{žo{RÇ͚šoËÍžÓđșƒŐ˜HžŐÁËöƒT3fÓǀ͏šsŐ -ț+€Žœbœ-ŐŐŐđ{T€ž#ӞˊĂì“ Ó\qÛ…#šH99`Őš#fy```{œw`Șé`b{Ć{ŽRÍfŒfwÍ;…‰{ÍJ/œœ}oo5jo{źČ-–{öƒT7öœáföÍD)fîs–""Îű*P’ÊJŸ|èRŒÜZČț>nÂäb¶ö€ü 4 v æ ü -’ -Ț * v à D € š Æ ( d ¶>î,nzŠ ¶Ț2fț–f€@ ûû/ÄÔì1ÔìÔì0!%!!füsüćț–űòr)ȘțÓm!(/œ@U" - -'&( -/)/))/B" -) *!††#Љ*Љ- -) " & 0üK° TXčÿÀ8YK° TK°T[K°T[Xč@8Y<ìô<ü<ôäì1/äìÄÔäì2Äîî99990KSXíí9í9íY"#.'5.546753.'>54&ŽdiÒjfŃoĘÉÚÌd]źSSŻ\ăÖăÖdtzqá{țÓ---Ž@AÈ$Ź–ŁŒëèŻ*.țU#ޜ©Ăš jXV`ŐțOnZXhÙÛ #@ œ - Üü<ü<ì1/Ô<ü<Ä0!!#!5!ź-ęÓšęÓ-ęÓȘęÓ-Ș-žÿĂț@ žƒüìÔÌ1üì073#đÓ€RțŹțÀ@d߃¶œÜÌ1Ôì0!!dęćƒ€ÿBČŐ-@BŸ/Ä991ôì0KSXííY"3#ȘęűȘŐùm‡ÿăđ #@   ‘Œ üìôì1äôìî0"32'2#"‹œœû țśûûțś PțÍțÌțÍțÍ3343 țsț†ț‡țsyzáZŐ -@@B     ÔK°TXč@8YìÄüì1/ì2ôìÔì0KSXY"Ž]7!5%3!!țJț™eÊJü€ȘsHžHúŐȘ–Jđž@'BĄ”  ‘  - -üK°TK°T[K°T[XčÿÀ8YÄÔìÀÀ91/ì2ôìôì0KSXíí°9Y"@2UVVzzv‡tvust‚†‚‚‚šš]]%!!567>54&#"5>32‰ÁüLs3aM§†_ÓxzÔXèE[țôȘȘȘw‘:m—Iw–BCÌ12èÂ\„pțëœÿăsđ(p@. -† †     “  “#‘ŒŁ)&  )üK°TK°T[Xč ÿÀ8YÄÄÔìôì991ìäôäìæîîîî90@ daa d!]!"&'532654&+532654&#"5>32?‘ŁțĐțè^ÇjTÈmŸÇ脟¶•žŁ˜SŸrsÉYæ Ž%ĐĘò%%Ă12–„•Šwps{$&Ž ŃČ|«d€Ő @   B    - ÜK° TK° T[Xč ÿÀ8YÔ<Äì291/äÔ<ì290KSXÉÉY"@* *HYiwŠ+&+6NO O Vfuz… ]] !33##!5țț5țŐŐÉę^%üăÍü3šț `ĂžÿădŐ^@#†  ‰   Œ€ - -üK°TK°T[XčÿÀ8YK°TXč@8YÄÔìÄî1ääôìæîțÄî90!!>32!"&'532654&#"Ęę ,X,ú$țÔțï^ĂhZÀk­ÊÊ­QĄTŐȘț’țîêńțő Ë10¶œœ¶$&ÿă–đ $X@$ †   „  ‰"‘Œ% " !%üììôìä1äôäüäîîî90@ËËÍÍÍËË€Č]]"32654&.#">32# !2€ˆŸŸˆˆŸŸ L›LÈÓ;ČkáțđâțęțîPL›;șąĄ»»Ąąșyž$&țòțïW]țïëæțêyb„‹ÿă‹đ #/C@%  ' - ‘Œ'Ł0 $*$ !0üÄìôÄìîî991ìäôìîî990"32654&%.54$32#"$54632654&#"‹„„Š„ț„‚‘ÿȚßț‘’Łțśśśțś€H‘ƒ‚““‚ƒ‘ڇ‡š›†‡šV ȀłĐĐł€Č "ƏÙèèُÆat‚‚tt‚‚ÿă‡đ$X@#†  -„ ‰ ‘Œ%!"" %üìäôìì1äôìæțőîî90@ÄÂÀÀÀÂÄȘŒé]]7532#"543 !"&2654&#"áLœKÈÓ:Člàțûâț±țćLœ>ˆŸŸˆˆŸŸž$& V\ëæțsț†țŸț[—șąĄ»»ĄąșđĂ#@ƒЃü<ì21/ìôì073#3#đÓÓÓÓțț#țhŐ -Â@A - - -  - - -B•    - ÔÄ91/<äÔì90KSXííííííííY"Č ]@BXvpŒ  VXP ghxv|rwx‡ˆ€ ˜™–]] !3#!#Œțî%ț{ć9҈ę_ˆŐęźú+țÉìŐ C@#• - •• -­ . !üì2üìÔì9991/ììôìî90Č"]!2654&#!2654&#%!2#!“DŁŁțŒ+”‘‘”ț çú€|•„țđûęèÉęʇ‹Œ…fț>orqpŠÀ±‰ą ˘ÈÚsÿă'đ6@ Ąź -•Ąź•‘Œ 0üì2ì1äôìôìîöî0Ž].# !267# !2'fç‚ÿțđ‚çfjí„ț­țz†S†íbŐ_^țÇțŰțÙțÇ^_ÓHHŸghŸGɰŐ.@• • - 2 üìôì99991/ìôì0Č`]3 !%! )“ô5țáțËțBŸČ–țhțPța/ûw.,Šț—ț€ț~ț–É‹Ő .@•••­ -  üì2ÔÄÄ1/ììôìî0Č ]!!!!!!ɰęÇę9űü>ŐȘțFȘęăȘsÿă‹đ9@ ••Ąź•‘Œ43 üìüäüÄ1äôìôìțÔî990%!5!# !2.# !26Ăț¶uțæ țąțu‹^’opü‹țîțíkšŐ‘ŠęSU™mn™HFŚ_`țÎțŃțÒțÎ%ÉjŐ%@ •:üìì1/äì0@ 0P€€]3!!ÉÊŚü_ŐúŐȘÉŐ ż@4 -  - - B -Ż   -> - üìüì91/<Äì290KSXííííY"Čp]@V  - -&& & - -45 -i|{y -€‚‚ - #,'( 4<VY ej vy •›]]! !###É-}-ĆțËțÄŐüűú+üúáÉ3Ő y@BŻ6 -üìüì991/<ì2990KSXííY"Č ]@068HGif€ FIWXeiy…Š•šŸ ]]!3!#É–ÄțđęjÄŐûáú+áûɍŐ:@••  - ? üì2üì91/ôìÔì0@ ?_Ż]32654&#%!2+#“țššț8ÈûțÿûțÊ/ęϒ‡†’ŠăÛĘâęšÉTŐ±@5  -B• •    -? - üì2üÄì99991/<ôìÔì9990KSXíí9Y"Č@]@Bz%%%&'&&& 66FFhuuwˆˆ˜˜]]#.+#! 32654&#A{>ÍÙżJ‹xÜÊÈüƒę‰ț’••’Œ~țh–bę‰ŐÖۍșOęƒ…‡ÿăąđ'~@<    -B - Ą”••”%‘Œ( - "-"(ÜÄìüìä99991äôäìîöîÆ90KSXí9í9Y"Č)]¶)/)O)].#"!"&'532654&/.54$32HsÌ_„łwŠzâŚțĘțçjï€{ìr­Œ‡š{âÊőiÚ€Ć76€vce+Ù¶Ùà0/ĐEFˆ~n|-À«Æä&ÿúéŐJ@•@@Ôäüä1/ôì20K° -TXœ@ÿÀ878Y@  @ p Ÿ ]!!#!ïęîËęîŐȘúŐ+Čÿă)Ő@@ •Œ  -8AüK°TXčÿÀ8Yìüì1ä2ôì99990¶Ÿ]332653! ČËźĂÂźËțßțæțćțßŐüuđÓÓđ‹ü\țÜțÖ*$hŐ·@'BŻÔÄ91/ì290KSXííííY"ČP]@b*GGZ}ƒ *&&))% 833<<7HEEIIGYVfiizvvyyu€˜—)]]!3 3JęÆÓÙÚÒęÇŐûéú+ÿìțțŹ”©ÄÄ1Ôì0!5ûŰțŹ{ÿă-{ -%Œ@'  ©č †șč#žŒ   E&üìÌÔì22991/ÄäôüôìÆîî9990@n0000 0!0"?'@@@@ @!@"PPPP P!P"P'p'…‡‡‡ ‡!…"' 'đ'000 0!@@@ @!PPP P!``` `!ppp p!€€€ €!]]"326=7#5#"&5463!54&#"5>32Ÿßʁo™čžž?ŒˆŹËęû§—`¶TeŸZóđ3f{bsÙŽ)LęȘfaÁąœÀ‹..Ș''üșÿ〠8@č  čŒž—G Füì22ôì1/ìäôÄìÆî0¶`€ ]4&#"326>32#"&'#3槒’§§’’§ęŽ:±{ÌÿÿÌ{±:čč/ËççËËççRdațŒțűțűțŒadšqÿăç{?@†ˆ† ˆ -ččžŒ HEüä2ì1äôìțôîőî0@ € ].#"3267#"!2çNPłÆÆłPNM„]ęțÖ-Uą5Ź++ăÍÍă++Ș$$>:#qÿăZ8@ččŒž—G Eüìôì221/ìäôÄìÄî0¶`€ ]3#5#"3232654&#"ąžž:±|ËÿÿË|±ęǧ’’šš’’§¶^ùìšdaDDațËççËËççqÿă{p@$ †ˆ©č »čž ŒKEüìôìÄ91äôìäîîôî90@)?p Đđ?????,// , -ooooo ]q]!3267# 32.#"üČ Í·jÇbcĐkțôțÇ)üâž„ˆšč^ZŸÇ44ź*,8 -CțĘÄ—ŽźžqțVZ{ (J@#  †čč&#ž'Œ čœ& G E)üÄìôì221/ÄäìäôÄìțŐî990¶`*€* *]4&#"326!"&'5326=#"3253ą„•”„„”•„žțțúaŹQQžR”Ž9Č|ÎüüÎ|Č9ž=ÈÜÜÈÇÜÜëțâțéł,*œż[cb::bcȘșd4@ ‡ž — -N  Füì2ôì1/<ìôÄì90Č`]#4&#"#3>32dž||•ŹččBłuÁÆ€ę\žŸžŸ€ę‡ęžedïÁy+@Ÿ±ŒFü<ì21/äüì0@  @ P ` p ]3#3#Ážžžž`û éÁy"·—Füì1/ì0@ @P`pđ]3#Ážžùìș{"Z@&  ‡ žŒPPF#üì2üüüì91/<<äô<Äì290@0$P$p$$ $ $ż$ß$ÿ$ ]>32#4&#"#4&#"#3>32)EÀ‚ŻŸčruŠčrwŠčč?°yz«‰|vőâę\žĄœŸ€ę‡žą›żŁę‡`źgb|șd{6@ ‡ž Œ -N  Füì2ôì1/<äôÄì90Ž`Ï]#4&#"#3>32dž||•ŹččBłuÁÆ€ę\žŸžŸ€ę‡`źedïqÿău{ J@čč žŒ QEüìôì1äôìî0@#?{{  - {  { đ]"32654&'2#"s”Ź«•“ŹŹ“đțîđńțïßçÉÉçèÈÇéœțÈțìțíțÇ98șțV€{>@ččžŒœŒ GFüì22ôì1äääôÄìÄî0@ `€ à]%#3>32#"&4&#"326sčč:±{ÌÿÿÌ{±8§’’§§’’§šęź -ȘdațŒțűțűțŒaëËççËËççșJ{0@  ‡ž Œ -FüÄì21/äôìÄÔÌ90ŽPŸ].#"#3>32JI,œ§čč:ș….ŽËŸęČ`źfcoÿăÇ{'ç@<  S  -SB - †‰†‰čč%žŒ( - R"E(üÄìÔìä99991äôìțőîőî90KSXí9í9Y"Č']@m -  . , -, , ; ; -; ; $( -( *//*(() )!$'† -† † †  -    '/)?)_))€)) )đ)]]q.#"#"&'532654&/.54632‹NšZ‰‰b”?Ä„śŰZĂlfÆa‚Œe«@«˜àÎfŽ?ź((TT@I!*™‰œ¶##Ÿ55YQKP%$•‚žŹ7òž8@©Œ‡ -  Fü<Äü<Ä2991/ìô<Äì2990ČŻ]!!;#"&5#53w{ț…KsœœŐą‡‡žțꠉNšŸÒ`>źÿăX{;@ ‡Œ -Œž  NFüìô9ì21/ää2ôÄì90ŽoÀ]332653#5#"&źž||•­žžC±uÁÈÏșŠęaŸŸŸ€{û Źfcđšÿÿof'6ÿ7ÿÿqÿăuf&,8sŽ+1déœy”©/Ì1Ôì0!!d8űÈyÛHźF·ƒÔì1Ôì03#ÛÓÓFțsîRf1@ ŽłDÔì1ôì0K° TK°T[XœÿÀ@878Y3#‹Çțș™fțˆÁy{,@ žżFüì91/ìä0@4D@P`p]3#Ážž\`û {Áî?f7@ ŽłuÜì91ôì290K° TK°T[XœÿÀ@878Y3#'#¶”ő‹ŽŽ‹fțˆőő2˜ ˜Ł § Č œÉÚ ë 4„ Ùä 0è  . 6 L b "z :œ %:Ö h; ;x ;ŽCopyright (c) 2003 by Bitstream, Inc. All Rights Reserved. -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. -DejaVu changes are in public domain -DejaVu SansBookDejaVu SansDejaVu SansVersion 2.37916dd0+DejaVuSansDejaVu fonts teamhttp://dejavu.sourceforge.netFonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below) - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr.http://dejavu.sourceforge.net/wiki/index.php/LicenseDejaVu SansBookCopyright (c) 2003 by Bitstream, Inc. All Rights Reserved. -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. -DejaVu changes are in public domain -DejaVu SansBookDejaVu SansDejaVu SansVersion 2.37DejaVu fonts teamhttp://dejavu.sourceforge.netFonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below) - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr.http://dejavu.sourceforge.net/wiki/index.php/LicenseDejaVu SansBookÿŰZ9$%&'(*/01356789BDEFGHJKLOPQRSUVWXt{łĂŚŰÿÿ -endstream -endobj -15 0 obj -<< /Ascent 759 -/CapHeight 759 -/Descent -240 -/Flags 4 -/FontBBox [-1020 -462 1793 1232] -/FontFile2 14 0 R -/FontName /916dd0+DejaVuSans -/ItalicAngle 0 -/StemV 0 -/Type /FontDescriptor -/XHeight 0 ->> -endobj -16 0 obj -<< /Length 627 ->> -stream -/CIDInit /ProcSet findresource begin -12 dict begin -begincmap -/CIDSystemInfo 3 dict dup begin - /Registry (Adobe) def - /Ordering (UCS) def - /Supplement 0 def -end def -/CMapName /Adobe-Identity-UCS def -/CMapType 2 def -1 begincodespacerange -<00> -endcodespacerange -10 beginbfrange -<2B><2D><002b> -<2F><36><002f> -<38><3A><0038> -<41><45><0041> -<4C><4E><004c> -<52><56><0052> -<61><65><0061> -<67><69><0067> -<6C><70><006c> -<72><75><0072> -endbfrange -9 beginbfchar -<20><0020> -<24><0024> -<47><0047> -<50><0050> -<5F><005f> -<92><00ed> -<99><00f4> -<2014> -<00b7> -endbfchar -endcmap -CMapName currentdict /CMap defineresource pop -end -end -endstream -endobj -17 0 obj -[317 0 0 0 636 0 0 0 0 0 0 837 317 360 0 336 636 636 636 636 636 636 636 0 636 636 336 0 0 0 0 0 0 684 686 698 770 631 0 774 0 0 0 0 557 862 748 0 603 0 694 634 610 731 684 0 0 0 0 0 0 0 0 500 0 612 634 549 634 615 0 634 633 277 0 0 277 974 633 611 634 0 411 520 392 633 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 277 0 0 0 0 0 0 611 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1000 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 317] -endobj -18 0 obj -<< /Length 19304 -/Length1 19304 ->> -stream -€`OS/2k"qÁhVcmapÌcvt >č1 4Tfpgm[kđàŹgaspK\ glyfV( °head&èJaì6hhea Æ<$$hmtxJ™À loca&~ ˆmaxp iH nameîŹ^Đ@<ëpost9Œ śK,0prep|aąçŒ§^žt„ź_<őàúŃ9àúŃ9fț–W€mțŽgW@íĂŒ3™3™Śf PfEd !țšmăÍfÉŽ3 ·, °%Id°@QX ÈY!-,°%Id°@QX ÈY!-,  °P° y žÿÿPXY°°%°%#á °P° y žÿÿPXY°°%á-,KPX ž(EDY!-,°%E`D-,KSX°%°%EDY!!-,ED-,°%°%I°%°%I`° ch ŠŠ#:Še:-A„€&ț%$!:$ú#"!:"ț!: ú»dțțțțțțțțț}ț}  Œ ț À  Y Œ €  -& Y @ -& țț €ȗ.Aúúțúú@ÿ}ÿ>țțüû,üțû,úțùűGù}űGśúöțőțôțó»òțńțđțïîțíì -íțì -ì@ëê -ë2ê -éúè‘èțçúæúć‘ćțäțățâțáțàțßțȚúĘÜĘdÜÛ ÛdÚÙ%ÚúÙ%ŰŃ%ŰúŚÖŚÖŐÖŐÔÓ Ô Ó ÒŃ%ÒúёŃ%Д Đ#ÏÎÏ&ÎÍÎÍ̑ÌËÊÉ»ÊțÉÈ]É»ɀÈ@ÿÇ%È]È@Ç%ÆțĆdĐÄțĂÂțÁțÀż:Àúż­ż:ŸœŸ2œŒœŒ»Œ»ș »ș č‘čțžț·¶”țŽțłțȱ°Ż­Żúź­źú­‘­Ź‘Ź}«țȘ&©țšț§țŠț„ -€țŁąŁțąą@Ą Ąú ‘ Ÿ‘Ÿúž” žțœ›»œț›š]›»›€š%š]š@™ț˜—.˜ț—.–‘–@ÿ•” • ” “‘“K’‘’ț‘‘%ŽțțŒț‹țŠț‰țˆ‡%ˆț‡%†ț…ț„2ƒ–‚țț€ -~ț}ț|ț{úzúyțwvŠwțvŠutuútsúr}qțpo,o,númúlúkțjțițhc h2gțf2ed -ețd -d@cb -c b -a`a–``_ -^ț]ț\\ț[Z[țZZYțXúWțV@ÿVțUțTSRQRúQQPOPúONONMLKLKJKJIJIHúGFGFEúDCDCBA%BúAA%@?@ț?>?>=< =< ;d:ț98ț7656%5455À4 - 44€32 33@2 10Š1ț00Š/ .-,:-ú,%,:+d*d)ț(''& %$#@+$#" "ú!!@  -%ž@‘ K}Kț%ú%dțțdÀ€  ú 2 -   € -  -@ țțț -ț -@údț  žd…++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++f3fŒé=ąúffŹTìŒbf…Tfm€fÍ3bq%€ŒșćfHZfmöĂđ99Xm=ȁČfuf°f9Ńœ{Ï{X3fLfLʚJ#ššDDÍÁf?š;ËËŐŐPŹŹw -Çò/XČ#öö/55îç3˜ŃX -š˜ŒÍććòsfŐ+ŐĂáŚćj-ŐŐđšjìáŐ!fű샊ű#^Í`ljìŒș3B3\Őššáfy```{ìĂžÍŸĘŐj\{šĘźș…ź`bššXîššŃÍšPËˋ‹1öđL`šÁ%Á!J–Jƒš7{ÉÁÁÁÁ–'žì}3˜ŃXyÍ9bœœœ“ž“žs&&&Xfț–f€Œ&&¶‰/ÄÔì1ÔìÔì0!%!!füsüćț–űòr)3ÆW -2327632#"'&'&5476Ć'( ï>‡ !ț~ț¶GH ".4F+Ž@xÂH )ț0ęü$Š™'*'2˜ ˜Ł§· ÇÓé ú 4Ž èó 0ś ' =  E  e … " :ż %:ù h;3 ;› ;±Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. -DejaVu changes are in public domain -DejaVu SansBoldDejaVu Sans BoldDejaVu Sans BoldVersion 2.371a10e1+DejaVuSans-BoldDejaVu fonts teamhttp://dejavu.sourceforge.netFonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below) - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr.http://dejavu.sourceforge.net/wiki/index.php/LicenseDejaVu SansBoldCopyright (c) 2003 by Bitstream, Inc. All Rights Reserved. -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. -DejaVu changes are in public domain -DejaVu SansBoldDejaVu Sans BoldDejaVu Sans BoldVersion 2.37DejaVu fonts teamhttp://dejavu.sourceforge.netFonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below) - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr.http://dejavu.sourceforge.net/wiki/index.php/LicenseDejaVu SansBoldÿŰZuni2713ÿÿ -endstream -endobj -19 0 obj -<< /Ascent 759 -/CapHeight 759 -/Descent -240 -/Flags 4 -/FontBBox [-1069 -415 1975 1175] -/FontFile2 18 0 R -/FontName /1a10e1+DejaVuSans-Bold -/ItalicAngle 0 -/StemV 0 -/Type /FontDescriptor -/XHeight 0 ->> -endobj -20 0 obj -<< /Length 376 ->> -stream -/CIDInit /ProcSet findresource begin -12 dict begin -begincmap -/CIDSystemInfo 3 dict dup begin - /Registry (Adobe) def - /Ordering (UCS) def - /Supplement 0 def -end def -/CMapName /Adobe-Identity-UCS def -/CMapType 2 def -1 begincodespacerange -<00><21> -endcodespacerange -1 beginbfrange -<20><21>[<0020><2713>] -endbfrange -endcmap -CMapName currentdict /CMap defineresource pop -end -end -endstream -endobj -21 0 obj -[348 837] -endobj -xref -0 22 -0000000000 65535 f -0000000015 00000 n -0000000109 00000 n -0000000158 00000 n -0000000215 00000 n -0000013770 00000 n -0000014119 00000 n -0000014289 00000 n -0000014454 00000 n -0000014623 00000 n -0000015140 00000 n -0000043133 00000 n -0000043347 00000 n -0000044033 00000 n -0000044563 00000 n -0000071780 00000 n -0000071989 00000 n -0000072668 00000 n -0000073181 00000 n -0000092554 00000 n -0000092768 00000 n -0000093196 00000 n -trailer -<< /Info 1 0 R -/Root 2 0 R -/Size 22 ->> -startxref -93222 -%%EOF diff --git a/Erros/relatorio_bruno-corgozinho_4-2.pdf b/Erros/relatorio_bruno-corgozinho_4-2.pdf deleted file mode 100644 index a806fcf..0000000 --- a/Erros/relatorio_bruno-corgozinho_4-2.pdf +++ /dev/null @@ -1,3911 +0,0 @@ -%PDF-1.3 -%ÿÿÿÿ -1 0 obj -<< /Creator -/Producer ->> -endobj -2 0 obj -<< /Pages 3 0 R -/Type /Catalog ->> -endobj -3 0 obj -<< /Count 2 -/Kids [5 0 R 9 0 R] -/Type /Pages ->> -endobj -4 0 obj -<< /Length 28303 ->> -stream -q -/DeviceRGB cs -0.03922 0.03922 0.03922 scn -0.0 771.89 595.28 70.0 re -f -0.97647 0.45098 0.08627 scn -0.0 771.89 8.0 70.0 re -f -1.0 1.0 1.0 scn - -BT -50.0 807.53 Td -/F2.0 20 Tf -[<5245454d205452414e53504f52> 20 <5445>] TJ -ET - -0.97647 0.45098 0.08627 scn - -BT -50.0 790.71 Td -/F1.0 10 Tf -[<53697374656d6120646520436f6e74726f6c6520646520437573746f73204c6f67ed737469636f73>] TJ -ET - -0.0 0.0 0.0 scn -0.97647 0.45098 0.08627 scn - -BT -40.0 723.556 Td -/F2.0 13 Tf -[<52454c41> 90 <54> 40 50 <414c20444520454e545245474153>] TJ -ET - -/DeviceRGB CS -0.97647 0.45098 0.08627 SCN -40.0 717.42 m -555.28 717.42 l -S -0.0 0.0 0.0 scn - -BT -40.0 701.522 Td -/F2.0 11 Tf -[<436f6e736f6c696461e7e36f3a207465737465>] TJ -ET - - -BT -40.0 688.432 Td -/F1.0 11 Tf -[<4d6f746f72> -15 <697374613a204272> -15 <756e6f20436f72676f7a696e686f>] TJ -ET - - -BT -40.0 676.434 Td -/F1.0 10 Tf -[<50> 50 <6572> -15 ] TJ -ET - -0.41961 0.44706 0.50196 scn - -BT -40.0 664.874 Td -/F1.0 10 Tf -[<5374617475733a2046696e616c697a616461>] TJ -ET - -0.0 0.0 0.0 scn -0.97647 0.45098 0.08627 scn - -BT -40.0 637.16 Td -/F2.0 13 Tf -[<454e5452454741532028323929>] TJ -ET - -0.97647 0.45098 0.08627 SCN -40.0 631.024 m -555.28 631.024 l -S -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -40.0 605.504 22.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -62.0 605.504 70.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -132.0 605.504 60.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -192.0 605.504 208.28 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -400.28 605.504 95.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -495.28 605.504 60.0 17.52 re -f -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 623.024 m -62.0 623.024 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 605.504 m -62.0 605.504 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 623.524 m -40.0 605.004 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -62.0 623.524 m -62.0 605.004 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -46.0 610.448 Td -/F2.0 8 Tf -[<23>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -62.0 623.024 m -132.0 623.024 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -62.0 605.504 m -132.0 605.504 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -62.0 623.524 m -62.0 605.004 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -132.0 623.524 m -132.0 605.004 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -68.0 610.448 Td -/F2.0 8 Tf -[<56> 50 <65ed63756c6f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -132.0 623.024 m -192.0 623.024 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -132.0 605.504 m -192.0 605.504 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -132.0 623.524 m -132.0 605.004 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -192.0 623.524 m -192.0 605.004 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -138.0 610.448 Td -/F2.0 8 Tf -[<4e46>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -192.0 623.024 m -400.28 623.024 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -192.0 605.504 m -400.28 605.504 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -192.0 623.524 m -192.0 605.004 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -400.28 623.524 m -400.28 605.004 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -198.0 610.448 Td -/F2.0 8 Tf -[<456e64657265e76f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -400.28 623.024 m -495.28 623.024 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -400.28 605.504 m -495.28 605.504 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -400.28 623.524 m -400.28 605.004 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -495.28 623.524 m -495.28 605.004 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -406.28 610.448 Td -/F2.0 8 Tf -[<5469706f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -495.28 623.024 m -555.28 623.024 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -495.28 605.504 m -555.28 605.504 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -495.28 623.524 m -495.28 605.004 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 623.524 m -555.28 605.004 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -530.752 610.448 Td -/F2.0 8 Tf -[<56> 60 <616c6f72>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.86667 0.86667 0.86667 SCN -40.0 579.008 m -62.0 579.008 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 593.132 Td -/F1.0 8 Tf -[<31>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 579.008 m -132.0 579.008 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 593.132 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 579.008 m -192.0 579.008 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 593.132 Td -/F1.0 8 Tf -[<4e46203532343038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 579.008 m -400.28 579.008 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 593.132 Td -/F1.0 8 Tf -[<52756120476f6e7a616c6f2042657263656f> 40 <2e2035332e204a> 20 <617264696d204d6f72> 10 <616973205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 583.884 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835352d343130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 579.008 m -495.28 579.008 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 593.132 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 579.008 m -555.28 579.008 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 593.132 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 552.512 m -62.0 552.512 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 566.636 Td -/F1.0 8 Tf -[<32>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 552.512 m -132.0 552.512 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 566.636 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 552.512 m -192.0 552.512 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 566.636 Td -/F1.0 8 Tf -[<4e46203532343036>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 552.512 m -400.28 552.512 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 566.636 Td -/F1.0 8 Tf -[<52756120526f676572204261636f6e2e203238392e204a> 20 <617264696d204d6f72> 10 <616973205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 557.388 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835352d333630>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 552.512 m -495.28 552.512 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 566.636 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 552.512 m -555.28 552.512 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 566.636 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 526.016 m -62.0 526.016 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 540.14 Td -/F1.0 8 Tf -[<33>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 526.016 m -132.0 526.016 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 540.14 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 526.016 m -192.0 526.016 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 540.14 Td -/F1.0 8 Tf -[<4e46203532343130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 526.016 m -400.28 526.016 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 540.14 Td -/F1.0 8 Tf -[<52756120526f676572204261636f6e2e2033322e204a> 20 <617264696d204d6f72> 10 <616973205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 530.892 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835352d333630>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 526.016 m -495.28 526.016 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 540.14 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 526.016 m -555.28 526.016 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 540.14 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 499.52 m -62.0 499.52 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 513.644 Td -/F1.0 8 Tf -[<34>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 499.52 m -132.0 499.52 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 513.644 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 499.52 m -192.0 499.52 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 513.644 Td -/F1.0 8 Tf -[<4e46203532333331>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 499.52 m -400.28 499.52 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 513.644 Td -/F1.0 8 Tf -[<5275612047656e6572> 10 <616c204a6f736520436f727265612e203338392e204368e1636172> 10 <6120436f636169612e2053e36f>] TJ -ET - - -BT -198.0 504.396 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d303930>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 499.52 m -495.28 499.52 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 513.644 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 499.52 m -555.28 499.52 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 513.644 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 473.024 m -62.0 473.024 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 487.148 Td -/F1.0 8 Tf -[<35>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 473.024 m -132.0 473.024 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 487.148 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 473.024 m -192.0 473.024 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 487.148 Td -/F1.0 8 Tf -[<4e46203532323935>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 473.024 m -400.28 473.024 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 487.148 Td -/F1.0 8 Tf -[<527561204d6172636f2041> 30 <7572656c696f204d6172> -15 <6c69616e692e203130332e204a> 20 <617264696d20416c6d65696461>] TJ -ET - - -BT -198.0 477.9 Td -/F1.0 8 Tf -[<5072> 10 <61646f> 40 <2e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d313730>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 473.024 m -495.28 473.024 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 487.148 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 473.024 m -555.28 473.024 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 487.148 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 446.528 m -62.0 446.528 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 460.652 Td -/F1.0 8 Tf -[<36>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 446.528 m -132.0 446.528 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 460.652 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 446.528 m -192.0 446.528 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 460.652 Td -/F1.0 8 Tf -[<4e46203532333435>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 446.528 m -400.28 446.528 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 460.652 Td -/F1.0 8 Tf -[<5275612044616e746520416d62726f73696f> 40 <2e2039382e204a> 20 <617264696d20416c6d65696461205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 451.404 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d323030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 446.528 m -495.28 446.528 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 460.652 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 446.528 m -555.28 446.528 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 460.652 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 420.032 m -62.0 420.032 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 434.156 Td -/F1.0 8 Tf -[<37>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 420.032 m -132.0 420.032 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 434.156 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 420.032 m -192.0 420.032 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 434.156 Td -/F1.0 8 Tf -[<4e46203532323835>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 420.032 m -400.28 420.032 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 434.156 Td -/F1.0 8 Tf -[<5275612053616e746f20416e746f6e696f> 40 <2e20342e204a> 20 <617264696d20416c6d65696461205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 424.908 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d323730>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 420.032 m -495.28 420.032 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 434.156 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 420.032 m -555.28 420.032 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 434.156 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 393.536 m -62.0 393.536 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 407.66 Td -/F1.0 8 Tf -[<38>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 393.536 m -132.0 393.536 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 407.66 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 393.536 m -192.0 393.536 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 407.66 Td -/F1.0 8 Tf -[<4e46203532323836>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 393.536 m -400.28 393.536 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 407.66 Td -/F1.0 8 Tf -[<527561204272656e6f2042657273612e203634612e204a> 20 <617264696d20416c6d65696461205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 398.412 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d323330>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 393.536 m -495.28 393.536 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 407.66 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 393.536 m -555.28 393.536 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 407.66 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 367.04 m -62.0 367.04 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 381.164 Td -/F1.0 8 Tf -[<39>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 367.04 m -132.0 367.04 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 381.164 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 367.04 m -192.0 367.04 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 381.164 Td -/F1.0 8 Tf -[<4e46203532323832>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 367.04 m -400.28 367.04 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 381.164 Td -/F1.0 8 Tf -[<527561204272656e6f2042657273612e20382e204a> 20 <617264696d20416c6d65696461205072> 10 <61646f> 40 <2e2053e36f2050> 40 <61756c6f> 40 <2e>] TJ -ET - - -BT -198.0 371.916 Td -/F1.0 8 Tf -[<5370> 35 <2e204365703a2030343835342d323330>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 367.04 m -495.28 367.04 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 381.164 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 367.04 m -555.28 367.04 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 381.164 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 340.544 m -62.0 340.544 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 354.668 Td -/F1.0 8 Tf -[<3130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 340.544 m -132.0 340.544 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 354.668 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 340.544 m -192.0 340.544 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 354.668 Td -/F1.0 8 Tf -[<4e46203532333530>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 340.544 m -400.28 340.544 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 354.668 Td -/F1.0 8 Tf -[<527561204d6172> -15 <696120416e746f6e69612046> 30 <6572> -25 <6e616e64657a2e20342e204a> 20 <617264696d20416c6d65696461>] TJ -ET - - -BT -198.0 345.42 Td -/F1.0 8 Tf -[<5072> 10 <61646f> 40 <2e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d313630>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 340.544 m -495.28 340.544 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 354.668 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 340.544 m -555.28 340.544 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 354.668 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 314.048 m -62.0 314.048 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 328.172 Td -/F1.0 8 Tf -[<3131>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 314.048 m -132.0 314.048 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 328.172 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 314.048 m -192.0 314.048 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 328.172 Td -/F1.0 8 Tf -[<4e46203532333339>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 314.048 m -400.28 314.048 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 328.172 Td -/F1.0 8 Tf -[<527561204c6972> -15 <696f20446f2056> 70 <616c65> 15 <2e2039362e204a> 20 <617264696d20417a616e6f2049692e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e>] TJ -ET - - -BT -198.0 318.924 Td -/F1.0 8 Tf -[<4365703a2030343835342d353630>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 314.048 m -495.28 314.048 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 328.172 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 314.048 m -555.28 314.048 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 328.172 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 287.552 m -62.0 287.552 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 301.676 Td -/F1.0 8 Tf -[<3132>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 287.552 m -132.0 287.552 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 301.676 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 287.552 m -192.0 287.552 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 301.676 Td -/F1.0 8 Tf -[<4e46203532333037>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 287.552 m -400.28 287.552 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 301.676 Td -/F1.0 8 Tf -[<527561204d6172> -15 <69612054686572657a612047616c76> 25 <616f204275656e6f> 40 <2e2038312e204a> 20 <617264696d205a696c64612e>] TJ -ET - - -BT -198.0 292.428 Td -/F1.0 8 Tf -[<53e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835362d333735>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 287.552 m -495.28 287.552 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 301.676 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 287.552 m -555.28 287.552 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 301.676 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 261.056 m -62.0 261.056 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 275.18 Td -/F1.0 8 Tf -[<3133>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 261.056 m -132.0 261.056 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 275.18 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 261.056 m -192.0 261.056 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 275.18 Td -/F1.0 8 Tf -[<4e46203532323936>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 261.056 m -400.28 261.056 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 275.18 Td -/F1.0 8 Tf -[<527561204775737461> 20 <76> 25 <6f204261636172> -15 <69736173> 15 <2e203130342e204a> 20 <617264696d205a696c64612e2053e36f2050> 40 <61756c6f> 40 <2e>] TJ -ET - - -BT -198.0 265.932 Td -/F1.0 8 Tf -[<5370> 35 <2e204365703a2030343835362d333832>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 261.056 m -495.28 261.056 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 275.18 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 261.056 m -555.28 261.056 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 275.18 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 234.56 m -62.0 234.56 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 248.684 Td -/F1.0 8 Tf -[<3134>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 234.56 m -132.0 234.56 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 248.684 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 234.56 m -192.0 234.56 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 248.684 Td -/F1.0 8 Tf -[<4e46203532333430>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 234.56 m -400.28 234.56 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 248.684 Td -/F1.0 8 Tf -[<5275612052656e65204a> 20 <75737465> 15 <2e20313562> 40 <2e204a> 20 <617264696d205a696c64612e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e>] TJ -ET - - -BT -198.0 239.436 Td -/F1.0 8 Tf -[<4365703a2030343835362d333835>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 234.56 m -495.28 234.56 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 248.684 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 234.56 m -555.28 234.56 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 248.684 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 208.064 m -62.0 208.064 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 222.188 Td -/F1.0 8 Tf -[<3135>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 208.064 m -132.0 208.064 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 222.188 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 208.064 m -192.0 208.064 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 222.188 Td -/F1.0 8 Tf -[<4e46203532323833>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 208.064 m -400.28 208.064 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 222.188 Td -/F1.0 8 Tf -[<527561204c756172204465204c6167> 10 <72> -15 <696d6173> 15 <2e2031322e204a> 20 <617264696d204e6f> 15 <76> 25 <6f20486f72> -15 <697a> 15 <6f6e7465> 15 <2e>] TJ -ET - - -BT -198.0 212.94 Td -/F1.0 8 Tf -[<53e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835362d323130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 208.064 m -495.28 208.064 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 222.188 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 208.064 m -555.28 208.064 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 222.188 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 181.568 m -62.0 181.568 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 195.692 Td -/F1.0 8 Tf -[<3136>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 181.568 m -132.0 181.568 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 195.692 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 181.568 m -192.0 181.568 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 195.692 Td -/F1.0 8 Tf -[<4e46203532323937>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 181.568 m -400.28 181.568 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 195.692 Td -/F1.0 8 Tf -[<527561204c756172204465204c6167> 10 <72> -15 <696d6173> 15 <2e2032372e204a> 20 <617264696d204e6f> 15 <76> 25 <6f20486f72> -15 <697a> 15 <6f6e7465> 15 <2e>] TJ -ET - - -BT -198.0 186.444 Td -/F1.0 8 Tf -[<53e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835362d323130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 181.568 m -495.28 181.568 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 195.692 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 181.568 m -555.28 181.568 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 195.692 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 155.072 m -62.0 155.072 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 169.196 Td -/F1.0 8 Tf -[<3137>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 155.072 m -132.0 155.072 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 169.196 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 155.072 m -192.0 155.072 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 169.196 Td -/F1.0 8 Tf -[<4e46203532333239>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 155.072 m -400.28 155.072 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 169.196 Td -/F1.0 8 Tf -[<54> 120 <72> 10 <61> 20 <76> 25 <657373612043687570696d2e2035372e204a> 20 <617264696d204e6f> 15 <76> 25 <6f20486f72> -15 <697a> 15 <6f6e7465> 15 <2e2053e36f>] TJ -ET - - -BT -198.0 159.948 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835372d343630>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 155.072 m -495.28 155.072 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 169.196 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 155.072 m -555.28 155.072 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 169.196 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 128.576 m -62.0 128.576 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 142.7 Td -/F1.0 8 Tf -[<3138>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 128.576 m -132.0 128.576 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 142.7 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 128.576 m -192.0 128.576 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 142.7 Td -/F1.0 8 Tf -[<4e46203532333139>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 128.576 m -400.28 128.576 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 142.7 Td -/F1.0 8 Tf -[<54> 120 <72> 10 <61> 20 <76> 25 <6573736120446f20426572> -15 <696d6261752e2033302e204a> 20 <617264696d204d6172> -15 <696c64612e2053e36f2050> 40 <61756c6f> 40 <2e>] TJ -ET - - -BT -198.0 133.452 Td -/F1.0 8 Tf -[<5370> 35 <2e204365703a2030343835372d343430>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 128.576 m -495.28 128.576 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 142.7 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 128.576 m -555.28 128.576 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 142.7 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 102.08 m -62.0 102.08 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 116.204 Td -/F1.0 8 Tf -[<3139>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 102.08 m -132.0 102.08 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 116.204 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 102.08 m -192.0 102.08 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 116.204 Td -/F1.0 8 Tf -[<4e46203532323834>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 102.08 m -400.28 102.08 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 116.204 Td -/F1.0 8 Tf -[<54> 120 <72> 10 <61> 20 <76> 25 <6573736120507265736964656e746520526f626572> -40 <746f204f72> -40 <74692e2036342e204a> 20 <617264696d204e6f> 15 <76> 25 <6f>] TJ -ET - - -BT -198.0 106.956 Td -/F1.0 8 Tf -[<486f72> -15 <697a> 15 <6f6e7465> 15 <2e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835372d353230>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 102.08 m -495.28 102.08 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 116.204 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 102.08 m -555.28 102.08 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 116.204 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 75.584 m -62.0 75.584 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 89.708 Td -/F1.0 8 Tf -[<3230>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 75.584 m -132.0 75.584 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 89.708 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 75.584 m -192.0 75.584 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 89.708 Td -/F1.0 8 Tf -[<4e46203532333135>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 75.584 m -400.28 75.584 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 89.708 Td -/F1.0 8 Tf -[<5275612053617069746963612e2031342e204a> 20 <617264696d2053e36f204a> 20 <756461732054> 120 <616465752e2053e36f2050> 40 <61756c6f> 40 <2e>] TJ -ET - - -BT -198.0 80.46 Td -/F1.0 8 Tf -[<5370> 35 <2e204365703a2030343835382d323330>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 75.584 m -495.28 75.584 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 89.708 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 75.584 m -555.28 75.584 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 89.708 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -/Stamp1 Do -Q - -endstream -endobj -5 0 obj -<< /ArtBox [0 0 595.28 841.89] -/BleedBox [0 0 595.28 841.89] -/Contents 4 0 R -/CropBox [0 0 595.28 841.89] -/MediaBox [0 0 595.28 841.89] -/Parent 3 0 R -/Resources << /Font << /F1.0 7 0 R -/F2.0 6 0 R ->> -/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] -/XObject << /Stamp1 10 0 R ->> ->> -/TrimBox [0 0 595.28 841.89] -/Type /Page ->> -endobj -6 0 obj -<< /BaseFont /Helvetica-Bold -/Encoding /WinAnsiEncoding -/Subtype /Type1 -/Type /Font ->> -endobj -7 0 obj -<< /BaseFont /Helvetica -/Encoding /WinAnsiEncoding -/Subtype /Type1 -/Type /Font ->> -endobj -8 0 obj -<< /Length 18183 ->> -stream -q -/DeviceRGB CS -0.97647 0.45098 0.08627 SCN -/DeviceRGB cs -0.03922 0.03922 0.03922 scn -40.0 784.37 22.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -62.0 784.37 70.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -132.0 784.37 60.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -192.0 784.37 208.28 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -400.28 784.37 95.0 17.52 re -f -0.0 0.0 0.0 scn -0.03922 0.03922 0.03922 scn -495.28 784.37 60.0 17.52 re -f -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 801.89 m -62.0 801.89 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 784.37 m -62.0 784.37 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 802.39 m -40.0 783.87 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -62.0 802.39 m -62.0 783.87 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -46.0 789.314 Td -/F2.0 8 Tf -[<23>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -62.0 801.89 m -132.0 801.89 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -62.0 784.37 m -132.0 784.37 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -62.0 802.39 m -62.0 783.87 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -132.0 802.39 m -132.0 783.87 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -68.0 789.314 Td -/F2.0 8 Tf -[<56> 50 <65ed63756c6f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -132.0 801.89 m -192.0 801.89 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -132.0 784.37 m -192.0 784.37 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -132.0 802.39 m -132.0 783.87 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -192.0 802.39 m -192.0 783.87 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -138.0 789.314 Td -/F2.0 8 Tf -[<4e46>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -192.0 801.89 m -400.28 801.89 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -192.0 784.37 m -400.28 784.37 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -192.0 802.39 m -192.0 783.87 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -400.28 802.39 m -400.28 783.87 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -198.0 789.314 Td -/F2.0 8 Tf -[<456e64657265e76f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -400.28 801.89 m -495.28 801.89 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -400.28 784.37 m -495.28 784.37 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -400.28 802.39 m -400.28 783.87 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -495.28 802.39 m -495.28 783.87 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -406.28 789.314 Td -/F2.0 8 Tf -[<5469706f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -495.28 801.89 m -555.28 801.89 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -495.28 784.37 m -555.28 784.37 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -495.28 802.39 m -495.28 783.87 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -555.28 802.39 m -555.28 783.87 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -1.0 1.0 1.0 scn - -BT -530.752 789.314 Td -/F2.0 8 Tf -[<56> 60 <616c6f72>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.86667 0.86667 0.86667 SCN -40.0 757.874 m -62.0 757.874 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 771.998 Td -/F1.0 8 Tf -[<3231>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 757.874 m -132.0 757.874 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 771.998 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 757.874 m -192.0 757.874 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 771.998 Td -/F1.0 8 Tf -[<4e46203532333038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 757.874 m -400.28 757.874 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 771.998 Td -/F1.0 8 Tf -[<527561204d6172> -15 <757061204d6972> -15 <696d2e20312e204a> 20 <617264696d2053616269e12049692e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e>] TJ -ET - - -BT -198.0 762.75 Td -/F1.0 8 Tf -[<4365703a2030343835362d353830>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 757.874 m -495.28 757.874 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 771.998 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 757.874 m -555.28 757.874 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 771.998 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 731.378 m -62.0 731.378 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 745.502 Td -/F1.0 8 Tf -[<3232>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 731.378 m -132.0 731.378 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 745.502 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 731.378 m -192.0 731.378 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 745.502 Td -/F1.0 8 Tf -[<4e46203532343133>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 731.378 m -400.28 731.378 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 745.502 Td -/F1.0 8 Tf -[<527561204167656e6f72204b6c617573736e6572> 50 <2e2032342e204a> 20 <617264696d204e6f> 15 <76> 25 <6f20486f72> -15 <697a> 15 <6f6e7465> 15 <2e>] TJ -ET - - -BT -198.0 736.254 Td -/F1.0 8 Tf -[<53e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835362d353130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 731.378 m -495.28 731.378 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 745.502 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 731.378 m -555.28 731.378 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 745.502 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 704.882 m -62.0 704.882 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 719.006 Td -/F1.0 8 Tf -[<3233>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 704.882 m -132.0 704.882 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 719.006 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 704.882 m -192.0 704.882 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 719.006 Td -/F1.0 8 Tf -[<4e46203532333936>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 704.882 m -400.28 704.882 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 719.006 Td -/F1.0 8 Tf -[<527561204167656e6f72204b6c617573736e6572> 50 <2e203234302e204a> 20 <617264696d204e6f> 15 <76> 25 <6f20486f72> -15 <697a> 15 <6f6e7465> 15 <2e>] TJ -ET - - -BT -198.0 709.758 Td -/F1.0 8 Tf -[<53e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835362d353130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 704.882 m -495.28 704.882 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 719.006 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 704.882 m -555.28 704.882 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 719.006 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 678.386 m -62.0 678.386 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 692.51 Td -/F1.0 8 Tf -[<3234>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 678.386 m -132.0 678.386 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 692.51 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 678.386 m -192.0 678.386 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 692.51 Td -/F1.0 8 Tf -[<4e46203532323939>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 678.386 m -400.28 678.386 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 692.51 Td -/F1.0 8 Tf -[<527561204167656e6f72204b6c617573736e6572> 50 <2e203238372e204a> 20 <617264696d204e6f> 15 <76> 25 <6f20486f72> -15 <697a> 15 <6f6e7465> 15 <2e>] TJ -ET - - -BT -198.0 683.262 Td -/F1.0 8 Tf -[<53e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835362d353130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 678.386 m -495.28 678.386 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 692.51 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 678.386 m -555.28 678.386 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 692.51 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 651.89 m -62.0 651.89 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 666.014 Td -/F1.0 8 Tf -[<3235>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 651.89 m -132.0 651.89 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 666.014 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 651.89 m -192.0 651.89 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 666.014 Td -/F1.0 8 Tf -[<4e46203532333233>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 651.89 m -400.28 651.89 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 666.014 Td -/F1.0 8 Tf -[<527561204c6976696e6f2046> 50 <61757374696e6f> 40 <2e20342e204a> 20 <617264696d2056> 70 <617267696e68612e2053e36f2050> 40 <61756c6f> 40 <2e>] TJ -ET - - -BT -198.0 656.766 Td -/F1.0 8 Tf -[<5370> 35 <2e204365703a2030343835372d363130>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 651.89 m -495.28 651.89 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 666.014 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 651.89 m -555.28 651.89 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 666.014 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 625.394 m -62.0 625.394 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 639.518 Td -/F1.0 8 Tf -[<3236>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 625.394 m -132.0 625.394 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 639.518 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 625.394 m -192.0 625.394 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 639.518 Td -/F1.0 8 Tf -[<4e46203532333034>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 625.394 m -400.28 625.394 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 639.518 Td -/F1.0 8 Tf -[<5275612048656e72> -15 <6971756520416c626572> -40 <747573> 15 <2e20322e204a> 20 <617264696d2056> 70 <617267696e68612e2053e36f2050> 40 <61756c6f> 40 <2e>] TJ -ET - - -BT -198.0 630.27 Td -/F1.0 8 Tf -[<5370> 35 <2e204365703a2030343835372d303135>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 625.394 m -495.28 625.394 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 639.518 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 625.394 m -555.28 625.394 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 639.518 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 598.898 m -62.0 598.898 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 613.022 Td -/F1.0 8 Tf -[<3237>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 598.898 m -132.0 598.898 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 613.022 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 598.898 m -192.0 598.898 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 613.022 Td -/F1.0 8 Tf -[<4e46203532333434>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 598.898 m -400.28 598.898 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 613.022 Td -/F1.0 8 Tf -[<5275612042656e69746f205361657a204761726369612e2038382e204a> 20 <617264696d2056> 70 <617267696e68612e2053e36f>] TJ -ET - - -BT -198.0 603.774 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835372d303830>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 598.898 m -495.28 598.898 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 613.022 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 598.898 m -555.28 598.898 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 613.022 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 572.402 m -62.0 572.402 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 586.526 Td -/F1.0 8 Tf -[<3238>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 572.402 m -132.0 572.402 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 586.526 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 572.402 m -192.0 572.402 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 586.526 Td -/F1.0 8 Tf -[<4e46203532333138>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 572.402 m -400.28 572.402 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 586.526 Td -/F1.0 8 Tf -[<5275612052696f2053616f204a6f7365> 15 <2e2037312e204a> 20 <617264696d204d6172> -15 <696c64612e2053e36f2050> 40 <61756c6f> 40 <2e205370> 35 <2e>] TJ -ET - - -BT -198.0 577.278 Td -/F1.0 8 Tf -[<4365703a2030343835372d323930>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 572.402 m -495.28 572.402 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 586.526 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 572.402 m -555.28 572.402 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 586.526 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -40.0 545.906 m -62.0 545.906 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -46.0 560.03 Td -/F1.0 8 Tf -[<3239>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -62.0 545.906 m -132.0 545.906 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -68.0 560.03 Td -/F1.0 8 Tf -[<474144455f313038>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -132.0 545.906 m -192.0 545.906 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -138.0 560.03 Td -/F1.0 8 Tf -[<4e46203532333431>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -192.0 545.906 m -400.28 545.906 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -198.0 560.03 Td -/F1.0 8 Tf -[<5275612045737465> 30 <76> 25 <616f20426572> -25 <6e617264692e2038322e204a> 20 <617264696d20416c6d65696461205072> 10 <61646f> 40 <2e2053e36f>] TJ -ET - - -BT -198.0 550.782 Td -/F1.0 8 Tf -[<50> 40 <61756c6f> 40 <2e205370> 35 <2e204365703a2030343835342d323230>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -400.28 545.906 m -495.28 545.906 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -406.28 560.03 Td -/F1.0 8 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.86667 0.86667 0.86667 SCN -495.28 545.906 m -555.28 545.906 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -517.816 560.03 Td -/F1.0 8 Tf -[<52242031352c3030>] TJ -ET - -0.97647 0.45098 0.08627 scn - -BT -40.0 522.572 Td -/F2.0 13 Tf -[<524553554d4f>] TJ -ET - -0.97647 0.45098 0.08627 SCN -40.0 516.436 m -555.28 516.436 l -S -0.0 0.0 0.0 scn -0.97647 0.45098 0.08627 scn -40.0 489.726 130.95045 18.71 re -f -0.0 0.0 0.0 scn -0.97647 0.45098 0.08627 scn -170.95045 489.726 119.04955 18.71 re -f -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 508.436 m -170.95045 508.436 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 489.726 m -170.95045 489.726 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 508.936 m -40.0 489.226 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 508.936 m -170.95045 489.226 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -0.0 0.0 0.0 scn - -BT -48.0 495.142 Td -/F2.0 9 Tf -[<5469706f>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -170.95045 508.436 m -290.0 508.436 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 489.726 m -290.0 489.726 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 508.936 m -170.95045 489.226 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -290.0 508.936 m -290.0 489.226 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN -0.0 0.0 0.0 scn - -BT -178.95045 495.142 Td -/F2.0 9 Tf -[<5175616e746964616465>] TJ -ET - -0.0 0.0 0.0 scn -1 w -0.0 0.0 0.0 SCN -40.0 489.726 m -170.95045 489.726 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 471.322 m -170.95045 471.322 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 490.226 m -40.0 470.822 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 490.226 m -170.95045 470.822 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 476.636 Td -/F1.0 9 Tf -[<456e7472656761204e6f72> -25 <6d616c>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -170.95045 489.726 m -290.0 489.726 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 471.322 m -290.0 471.322 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 490.226 m -170.95045 470.822 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -290.0 490.226 m -290.0 470.822 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -178.95045 476.636 Td -/F1.0 9 Tf -[<3239>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -40.0 471.322 m -170.95045 471.322 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 452.918 m -170.95045 452.918 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 471.822 m -40.0 452.418 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 471.822 m -170.95045 452.418 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 458.232 Td -/F1.0 9 Tf -[<5265746972> 10 <616461>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -170.95045 471.322 m -290.0 471.322 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 452.918 m -290.0 452.918 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 471.822 m -170.95045 452.418 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -290.0 471.822 m -290.0 452.418 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -178.95045 458.232 Td -/F1.0 9 Tf -[<30>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -40.0 452.918 m -170.95045 452.918 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 434.514 m -170.95045 434.514 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 453.418 m -40.0 434.014 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 453.418 m -170.95045 434.014 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 439.828 Td -/F1.0 9 Tf -[<42f46e> 10 <7573>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -170.95045 452.918 m -290.0 452.918 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 434.514 m -290.0 434.514 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 453.418 m -170.95045 434.014 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -290.0 453.418 m -290.0 434.014 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -178.95045 439.828 Td -/F1.0 9 Tf -[<30>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -40.0 434.514 m -170.95045 434.514 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 416.11 m -170.95045 416.11 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -40.0 435.014 m -40.0 415.61 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 435.014 m -170.95045 415.61 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -48.0 421.424 Td -/F1.0 9 Tf -[<446573636f6e746f>] TJ -ET - -1 w -0.0 0.0 0.0 SCN -170.95045 434.514 m -290.0 434.514 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 416.11 m -290.0 416.11 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -170.95045 435.014 m -170.95045 415.61 l -S -[] 0 d -1 w -0.0 0.0 0.0 SCN -290.0 435.014 m -290.0 415.61 l -S -[] 0 d -1 w -0.97647 0.45098 0.08627 SCN - -BT -178.95045 421.424 Td -/F1.0 9 Tf -[<30>] TJ -ET - -0.97647 0.45098 0.08627 scn - -BT -40.0 390.622 Td -/F2.0 16 Tf -[<54> 40 <4f> 40 <54> 90 <414c20412050> 100 <41> 50 <4741523a205224203433352c3030>] TJ -ET - -0.0 0.0 0.0 scn -0.0 0.0 0.0 SCN -0.7 w -40.0 323.07 m -277.64 323.07 l -S -317.64 323.07 m -555.28 323.07 l -S -0.0 0.0 0.0 scn - -BT -119.391 308.608 Td -/F2.0 9 Tf -[<4272756e6f20436f72> 15 <676f7a696e686f>] TJ -ET - - -BT -399.722 308.608 Td -/F2.0 9 Tf -[<5265656d2054> 80 <72616e73706f72> -20 <7465>] TJ -ET - -0.41961 0.44706 0.50196 scn - -BT -116.784 295.326 Td -/F1.0 8 Tf -[<417373696e61747572> 10 <6120646f204d6f746f72> -15 <69737461>] TJ -ET - - -BT -386.3 295.326 Td -/F1.0 8 Tf -[<417373696e61747572> 10 <6120646f2041646d696e69737472> 10 <61646f72>] TJ -ET - -0.0 0.0 0.0 scn -/Stamp1 Do -Q - -endstream -endobj -9 0 obj -<< /ArtBox [0 0 595.28 841.89] -/BleedBox [0 0 595.28 841.89] -/Contents 8 0 R -/CropBox [0 0 595.28 841.89] -/MediaBox [0 0 595.28 841.89] -/Parent 3 0 R -/Resources << /Font << /F1.0 7 0 R -/F2.0 6 0 R ->> -/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] -/XObject << /Stamp1 10 0 R ->> ->> -/TrimBox [0 0 595.28 841.89] -/Type /Page ->> -endobj -10 0 obj -<< /BBox [0 0 595.28 841.89] -/Length 363 -/Resources << /Font << /F1.0 7 0 R ->> ->> -/Subtype /Form -/Type /XObject ->> -stream -q -/DeviceRGB cs -0.0 0.0 0.0 scn -/DeviceRGB CS -0.0 0.0 0.0 SCN -0.7 w -0 J -0 j -[] 0 d -/DeviceRGB cs -0.41961 0.44706 0.50196 scn - -BT -171.876 86.256 Td -/F1.0 8 Tf -[<476572> 10 <61646f20656d2031372f30362f323032362031353a313420b7205265656d2054> 120 <72> 10 <616e73706f72> -40 <7465209720646f63756d656e746f20696e746572> -25 <6e6f>] TJ -ET - -/DeviceRGB cs -0.0 0.0 0.0 scn -Q - -endstream -endobj -xref -0 11 -0000000000 65535 f -0000000015 00000 n -0000000109 00000 n -0000000158 00000 n -0000000221 00000 n -0000028577 00000 n -0000028915 00000 n -0000029017 00000 n -0000029114 00000 n -0000047350 00000 n -0000047688 00000 n -trailer -<< /Info 1 0 R -/Root 2 0 R -/Size 11 ->> -startxref -48200 -%%EOF diff --git a/Erros/teste _ Reem LogĂ­stica_quando aperta a pre visualização do holerite.html b/Erros/teste _ Reem LogĂ­stica_quando aperta a pre visualização do holerite.html deleted file mode 100644 index 888642b..0000000 --- a/Erros/teste _ Reem LogĂ­stica_quando aperta a pre visualização do holerite.html +++ /dev/null @@ -1,252 +0,0 @@ - - - - - - - - teste | Reem LogĂ­stica - - - - - - - - - - - - - - - - - - - - - - - - -

- - - -
- -
-
G
- Reem LogĂ­stica -
-
-
-
- - - - - - -
- -
- -
- ← ConsolidaçÔes -
-
-
-

teste

- 📝 Rascunho -
-

- 📅 01/02/2026 → 28/02/2026 - · Criada por Administrador Gade -

-
-
- ✏ Continuar validação -
-
-
-
- -
-

Valor total da consolidação

-

R$ 5265,00

-
- -

đŸ‘„ Motoristas

-
-
-
-
-

Bruno Corgozinho

-

R$ 5265,00

-
-
- - 📄 RelatĂłrio Individual - đŸ§Ÿ Gerar Holerite -
-
-
-
-
- - - - - -
- - - - - - diff --git a/config/environments/development.rb b/config/environments/development.rb index 0e5a683..a092ede 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,7 +3,21 @@ require "active_support/core_ext/integer/time" Rails.application.configure do config.enable_reloading = true config.eager_load = false - config.consider_all_requests_local = true + # ⚠ SEGURANÇA — a pĂĄgina de debug do Rails ("Action Controller: Exception + # caught") mostra parĂąmetros, SESSÃO, cookies, IP do cliente, caminho do + # servidor e o trace inteiro. O servidor de teste/produção sobe com + # RAILS_ENV=development (docker-compose.yml), entĂŁo esse painel estava indo + # para a tela de qualquer pessoa que provocasse um erro. + # + # Agora o detalhe sĂł aparece quando ALGUÉM PEDE explicitamente, na mĂĄquina de + # desenvolvimento: `ERROS_DETALHADOS=true` no .env local. Sem a variĂĄvel, o + # visitante recebe a pĂĄgina estĂĄtica de public/500.html. + config.consider_all_requests_local = ENV['ERROS_DETALHADOS'] == 'true' + + # Sem isso, com consider_all_requests_local=false o Rails ainda tentaria + # renderizar o painel para requisiçÔes vindas de localhost. + config.action_dispatch.show_exceptions = :all + config.server_timing = true config.cache_store = :memory_store diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..bd5b27c --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,19 @@ +# config/initializers/filter_parameter_logging.rb +# +# ⚠ SEGURANÇA — sem esta lista, TODO parĂąmetro entra no log em texto puro: +# senha digitada no login, PIN do motorista, token de acesso do WhatsApp, cookie +# de sessĂŁo. O arquivo de log Ă© lido por quem tem acesso ao servidor e costuma +# ser copiado junto em backup e em suporte. +# +# Os nomes sĂŁo casados por SUBSTRING (`:senha` filtra `senha_confirmation`), e a +# lista Ă© em portuguĂȘs e inglĂȘs porque o projeto tem os dois (Devise usa +# `password`, o resto do sistema usa `senha`/`pin`). +Rails.application.config.filter_parameters += %i[ + passw password password_confirmation + senha senha_confirmation + pin pin_code pin_acesso + secret token _key crypt salt certificate otp ssn + authorization cookie session + auth_token access_token api_key + whatsapp_token twilio_auth_token smtp_senha smtp_password +] diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..5378aca --- /dev/null +++ b/public/404.html @@ -0,0 +1,29 @@ + + + + + + PĂĄgina nĂŁo encontrada · Reem Transporte + + + +
+

Reem Transporte

+

PĂĄgina nĂŁo encontrada

+

O endereço que vocĂȘ abriu nĂŁo existe ou foi movido.

+

Confira o link ou use o menu para chegar onde precisa.

+ Voltar ao inĂ­cio +
+ + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..d8cce64 --- /dev/null +++ b/public/422.html @@ -0,0 +1,29 @@ + + + + + + Não foi possível concluir · Reem Transporte + + + +
+

Reem Transporte

+

NĂŁo foi possĂ­vel concluir

+

A ação foi recusada por uma verificação de segurança.

+

Normalmente isso acontece quando a página ficou aberta tempo demais — recarregue e tente de novo.

+ Voltar ao inĂ­cio +
+ + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..0d335f0 --- /dev/null +++ b/public/500.html @@ -0,0 +1,29 @@ + + + + + + Algo deu errado do nosso lado · Reem Transporte + + + +
+

Reem Transporte

+

Algo deu errado do nosso lado

+

Registramos o problema e a equipe jĂĄ consegue ver o que aconteceu.

+

Tente de novo em alguns instantes. Se continuar, avise o administrador do sistema informando o que vocĂȘ estava fazendo.

+ Voltar ao inĂ­cio +
+ + -- 2.49.1 From a0aafe62fcb52eaa65d77f80e720a16072dc9cd300e1dcc348da91d8cc92d854 Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 23:43:36 -0300 Subject: [PATCH 20/40] =?UTF-8?q?Corre=C3=A7=C3=A3o=20da=20pagina=20de=20v?= =?UTF-8?q?ariaveis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 127 +++++++++++++++++- config/initializers/inflections.rb | 4 + ...izar_permissoes_do_perfil_administrador.rb | 40 ++++++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260826000005_sincronizar_permissoes_do_perfil_administrador.rb diff --git a/README.md b/README.md index 73657f4..eccea02 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**. **Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus) -**RepositĂłrio:** https://git.xenserver.com.br/Cludio-code/logistica-controle-custos +**RepositĂłrio:** https://git.xenserver.com.br/victor/Reem-Notas --- @@ -2928,7 +2928,7 @@ todos **sĂł texto**, com cores fixas no renderizador. | # | Ideia | Esforço | Observação | |---|---|---|---| -| B1 | **Seletor de emoji** nos campos de texto — hoje sĂł colando do sistema. Emoji Ă© o que dĂĄ cara de WhatsApp Ă  mensagem. | baixo | Um picker pequeno, sem dependĂȘncia externa (a CSP Ă© restritiva). Inserir no cursor: a mecĂąnica jĂĄ existe, Ă© a mesma dos chips de variĂĄvel. | +| B1 | ✅ **FEITO (26/08/2026)** — Seletor de emoji nos campos de texto. | baixo | Um picker pequeno, sem dependĂȘncia externa (a CSP Ă© restritiva). Inserir no cursor: a mecĂąnica jĂĄ existe, Ă© a mesma dos chips de variĂĄvel. | | B2 | **Bloco de imagem no e-mail** — `` com URL ou upload. | mĂ©dio | Cliente de e-mail bloqueia imagem remota por padrĂŁo → precisa de URL absoluta pĂșblica ou base64. | | B3 | **Bloco de imagem no WhatsApp** | **alto** | A ponte hoje sĂł faz `sendMessage` de **texto**. Exige `sendMessage(jid, { image })` no `server.js`, endpoint novo, envio multipart no `ClienteWhatsapp`, storage do arquivo e uma coluna no log de envios. **É o item mais caro da lista** — vale sĂł se a demanda for real. | | B4 | **Formatação do WhatsApp** (`*negrito*`, `_itĂĄlico_`, `~riscado~`, ``` `mono` ```) com botĂ”es — hoje o ADM precisa saber a sintaxe de cor. | baixo | No e-mail cada marca vira a tag equivalente. | @@ -2945,3 +2945,126 @@ no dia a dia. **B3 (imagem no WhatsApp) fica por Ășltimo** — Ă© o Ășnico que m Ruby e no schema ao mesmo tempo.
+ +--- + +
+🔐 Atualização 26/08/2026 — Perfis de acesso, variĂĄveis de mensagem e correção de vazamento nos erros + +TrĂȘs frentes numa rodada sĂł: **quem vĂȘ o quĂȘ** (perfis), **o que dĂĄ para escrever numa mensagem** +(variĂĄveis + emoji) e **o que o sistema mostrava a mais quando dava erro** (segurança). + +--- + +## 1. Perfis de acesso — o ADM monta o acesso sem deploy + +**Antes:** o acesso saĂ­a de `users.role` (admin/gerente/operador/motorista/externo) e de +`if admin? || gerente?` espalhado pelas policies. Tirar as ConfiguraçÔes de **um** gerente exigia +editar cĂłdigo e subir versĂŁo. + +**Agora:** `Administração → Perfis de acesso`. O ADM cria perfis marcando caixas agrupadas por ĂĄrea +e atribui o perfil Ă  pessoa em `UsuĂĄrios`. + +| Peça | Onde | O que faz | +|---|---|---| +| CatĂĄlogo de permissĂ”es | `app/models/permissao.rb` | **33 chaves** em 4 grupos (`dashboard`, `consolidacao`, `notificacao`, `admin`), cada uma com o **efeito real** escrito ("Baixar a planilha da operação — Ă© dado saindo do sistema"). É cĂłdigo, nĂŁo tabela: cada chave corresponde a um `authorize` de verdade. | +| Perfil | `app/models/perfil_acesso.rb` + `perfis_acesso` | Nome, descrição, lista de permissĂ”es (jsonb), `sistema` (nĂŁo apagĂĄvel) e `ativo`. | +| Ligação | `users.perfil_acesso_id` | **Nulo Ă© vĂĄlido**: sem perfil, vale o padrĂŁo do papel antigo (`Permissao::PADRAO_POR_ROLE`) — Ă© o que faz ninguĂ©m perder acesso no dia do deploy. | +| RĂ©gua | `User#pode?('chave')` | Todas as 13 policies e a navbar passaram a perguntar por **permissĂŁo**, nĂŁo por papel. | + +**Tipo de conta ≠ perfil de acesso.** O select antigo se chamava "Perfil de acesso" mas editava +`role`. Agora sĂŁo dois campos: **Tipo de conta** decide *como a pessoa entra* (motorista entra por +PIN e tem painel prĂłprio); **Perfil de acesso** decide *o que ela enxerga*. + +**Migração automĂĄtica** (`20260826000001..3`): cria os perfis `Administrador`, `Gerente`, +`Operador` e `Externo` a partir do comportamento atual e atribui a cada usuĂĄrio o perfil do papel +que ele jĂĄ tinha. + +### TrĂȘs falhas de acesso corrigidas junto + +| Falha | O que acontecia | Correção | +|---|---|---| +| **Auto-promoção a admin** | `UserPolicy#update?` libera editar a prĂłpria ficha e o form aceitava `role` → qualquer usuĂĄrio logado virava admin editando o prĂłprio cadastro. | `role` e `perfil_acesso_id` sĂł entram nos strong params de quem tem `admin.usuarios_gerenciar`, e **nunca sobre si mesmo** (`UserPolicy#alterar_acesso?`). | +| **`externo` via o financeiro** | `DashboardController` usava `skip_authorization`: o papel "Externo (sĂł dashboard)" enxergava custo, pagamento e o PDF financeiro. | Dashboard passou pelo Pundit; sem `dashboard.financeiro` os blocos de custo **nem sĂŁo carregados**. | +| **Conta inativa logava** | SĂł o login por PIN checava `ativo?`; conta de e-mail/senha desativada entrava normalmente. | `User#active_for_authentication?` (Devise) + mensagem em pt-BR. | + +### Duas travas que evitam tiro no pĂ© + +- **Último administrador** (`User.administradores_de_acesso`): nĂŁo dĂĄ para salvar um perfil, trocar + alguĂ©m de perfil, desativar ou excluir usuĂĄrio se isso deixar o sistema **sem ninguĂ©m ativo** + capaz de mexer em perfis e usuĂĄrios. Sem isso, a volta seria sĂł por console. +- **Tela `/sem-acesso`**: a raiz do app Ă© o dashboard e o "acesso negado" mandava para a raiz — um + perfil sem `dashboard.ver` entraria em **loop** (nega → raiz → nega). Agora o destino Ă© a primeira + tela que a pessoa pode abrir (`User#home_rota`), e quem nĂŁo pode abrir nenhuma cai numa pĂĄgina que + explica a quem pedir. + +--- + +## 2. VariĂĄveis de mensagem — de 6 fixas para 31 + as suas + +**Antes:** `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho. O ADM via 6 variĂĄveis +e qualquer texto com nĂșmero ("entregas do mĂȘs") exigia deploy. + +**Agora:** `Notificacao::CatalogoVariaveis`, com quatro origens — e o editor mostra **todas**, +agrupadas: + +| Origem | Exemplos | Observação | +|---|---|---| +| **Do que aconteceu** | `{{motorista}}`, `{{valor}}`, `{{consolidacao}}`, `{{nf}}` | SĂł tĂȘm valor no gatilho que as produz. As de outro gatilho aparecem **apagadas com "·"**, avisando que sairiam em branco — liberdade com aviso, em vez de lista curta. | +| **Empresa e data** | `{{empresa}}`, `{{data}}`, `{{hora}}`, `{{dia_semana}}`, `{{mes}}`, `{{mes_ano}}`, `{{link_sistema}}` | Sempre disponĂ­veis. | +| **NĂșmeros do sistema** | `{{entregas_mes}}`, `{{entregas_hoje}}`, `{{motoristas_ativos_mes}}`, `{{consolidacoes_abertas}}`, `{{total_a_pagar_mes}}`, `{{total_pago_mes}}`, `{{total_consolidado_mes}}` | **Consultam o banco na hora do envio.** É o que permite mandar relatĂłrio por WhatsApp/e-mail sem programação. | +| **Suas variĂĄveis** | `{{telefone_suporte}}`, `{{horario_atendimento}}`
 | Tela nova: `NotificaçÔes → VariĂĄveis`. Valor fixo, mudou ali mudou em todas as mensagens. | + +**Duas decisĂ”es que importam:** + +1. **Resolução sob demanda** (`Notificacao::ResolvedorVariaveis`): sĂł a variĂĄvel realmente escrita no + template vira consulta. Resolver o catĂĄlogo inteiro em todo disparo faria dezenas de queries para + preencher variĂĄvel que ninguĂ©m usou. +2. **O contexto do gatilho sempre vence** o catĂĄlogo — o `{{valor}}` daquele pagamento nunca Ă© + trocado por um nĂșmero genĂ©rico. E nenhuma variĂĄvel derruba um disparo: falha vira texto vazio. + +**Emoji** (item B1 do backlog abaixo — ✅ feito): paleta de 27 emojis no editor, insere no cursor +reusando a mecĂąnica dos chips de variĂĄvel. Sem dependĂȘncia externa, por causa da CSP. + +--- + +## 3. Segurança — o que a pĂĄgina de erro mostrava + +O servidor sobe com `RAILS_ENV=development` (`docker-compose.yml`), e development tinha +`consider_all_requests_local = true`. A pĂĄgina **"Action Controller: Exception caught"** mostra +parĂąmetros, **sessĂŁo** (`session_id`, `_csrf_token`, id do usuĂĄrio logado), cookies, IP do cliente, +caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocasse um erro. + +| Problema | Correção | +|---|---| +| Painel de debug na tela do usuĂĄrio | Detalhe sĂł com `ERROS_DETALHADOS=true` no `.env` (mĂĄquina de desenvolvimento). No servidor, sai a pĂĄgina estĂĄtica. | +| **Senha e PIN em texto puro no log** — nĂŁo existia `filter_parameter_logging.rb` neste projeto | Criado, com nomes em portuguĂȘs e inglĂȘs (`senha`, `pin_code`, `password`, `token`, `whatsapp_token`
). **Era o pior dos trĂȘs**: log Ă© permanente e vai junto em backup e em suporte. | +| Tela em branco ao desligar o painel | `public/500.html`, `404.html` e `422.html` em portuguĂȘs, sem CSS externo (precisam funcionar com a aplicação fora do ar). | +| PĂĄginas de debug salvas versionadas | `Erros/` saiu do git e entrou no `.gitignore`. Nos arquivos hĂĄ `session_id`, token CSRF, id de usuĂĄrio e IPs — **nĂŁo hĂĄ cookie de sessĂŁo assinado**, entĂŁo ninguĂ©m entra no sistema com aquilo, mas nĂŁo Ă© conteĂșdo de repositĂłrio. | + +> ⚠ **Correção estrutural ainda pendente:** subir o servidor com `RAILS_ENV=production` +> (`production.rb` jĂĄ tem `consider_all_requests_local = false` e `force_ssl = true`). Enquanto isso +> nĂŁo acontece, o `ERROS_DETALHADOS` cobre o buraco. + +--- + +## 4. Telas que mudaram nesta rodada + +- **Dashboard de OperaçÔes**: no modo Operação o perĂ­odo **nĂŁo recorta mais** os KPIs — eles voltam a + bater 1-para-1 com a planilha entregue ao cliente. Uma operação mensal executa entregas fora do mĂȘs + do nome (a `gade_entregas_emad_ago_2026` rodou de 30/07 a 12/08): filtrar "01/08 → hoje" mostrava + **1146 das 2021 NFs**. O perĂ­odo escolhido virou referĂȘncia no cabeçalho. +- **ConsolidaçÔes**: aba **"Por motorista"** na prĂłpria tela (nĂŁo Ă© tela nova), com o total de cada + um, drill-down inline mostrando as consolidaçÔes que compĂ”em o valor, PDF completo/resumo/por + motorista, e o filtro **Consolidado (fechadas) × Geral (com rascunhos)**. O recorte de datas foi + unificado com o do dashboard financeiro (consolidaçÔes que **cruzam** o perĂ­odo + as pagas nele), + que era a origem de dois valores diferentes para o mesmo motorista. +- **Painel do motorista**: refeito. PerĂ­odo em chips (Este mĂȘs / MĂȘs passado / 3 meses / Tudo), card + do valor **fechado** com "jĂĄ recebido" e "a receber", PDF do perĂ­odo, e a composição do valor. + O card de estimativa **nunca aparece junto do fechado** (o estimado costuma ser maior e virava + cobrança); com o perĂ­odo jĂĄ fechado, ele dĂĄ lugar Ă  contagem de entregas, sem R$. +- **Listas longas** (`carrossel_controller.js`): 5 itens por pĂĄgina no celular, 10 atĂ© 1366px, lista + inteira acima disso. As setas flutuam nas laterais em tela com espaço e viram barra no rodapĂ© no + celular, onde nĂŁo hĂĄ faixa lateral livre. + +
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 57f3ef6..d629526 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -9,4 +9,8 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| # Sem isto, "perfis".singularize vira "perfi" e as rotas de perfil de acesso # gerariam helpers como `new_admin_perfi_path`. inflect.irregular 'perfil', 'perfis' + # Idem para variĂĄvel: sem esta linha "variaveis".singularize vira "variavei" e + # `resources :variaveis` gera `new_admin_variavei_path` — a tela quebra com + # NameError na primeira chamada de rota. + inflect.irregular 'variavel', 'variaveis' end diff --git a/db/migrate/20260826000005_sincronizar_permissoes_do_perfil_administrador.rb b/db/migrate/20260826000005_sincronizar_permissoes_do_perfil_administrador.rb new file mode 100644 index 0000000..6d7b62b --- /dev/null +++ b/db/migrate/20260826000005_sincronizar_permissoes_do_perfil_administrador.rb @@ -0,0 +1,40 @@ +# Garante que o perfil "Administrador" tenha TODAS as permissĂ”es do catĂĄlogo. +# +# Por que existe: 20260826000003 Ă© idempotente — se o perfil jĂĄ foi criado, ela +# nĂŁo volta a mexer nele. Isso Ă© o certo para nĂŁo desfazer ajuste do ADM, mas +# significa que uma permissĂŁo acrescentada ao catĂĄlogo DEPOIS daquele deploy +# (foi o caso de `notificacao.variaveis`) nunca chega ao perfil: o item some do +# menu e a URL responde "sem permissĂŁo", sem nada indicando o porquĂȘ. +# +# Mexe SÓ no perfil Administrador, e SÓ acrescentando: ele Ă©, por definição, +# "acesso total ao sistema". Perfis criados pelo ADM e os demais de sistema +# ficam como estĂŁo. +class SincronizarPermissoesDoPerfilAdministrador < ActiveRecord::Migration[7.1] + TODAS = %w[ + dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas + consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.editar_finalizada + consolidacao.finalizar consolidacao.arquivar consolidacao.excluir + consolidacao.registrar_pagamento consolidacao.cancelar_pagamento + consolidacao.gerir_motoristas consolidacao.exportar_pdf + notificacao.contatos notificacao.contatos_gerenciar notificacao.contatos_excluir + notificacao.eventos notificacao.eventos_gerenciar notificacao.disparar_manual + notificacao.variaveis notificacao.envios notificacao.credenciais notificacao.whatsapp_sessao + admin.usuarios admin.usuarios_gerenciar admin.perfis + admin.configuracoes admin.configuracoes_editar admin.auditoria + admin.edicao_lancamento admin.planilha_simpli_route + ].freeze + + def up + return unless table_exists?(:perfis_acesso) + + execute(<<~SQL.squish) + UPDATE perfis_acesso + SET permissoes = #{quote(TODAS.to_json)}::jsonb, updated_at = NOW() + WHERE sistema = true AND LOWER(nome) = 'administrador' + SQL + end + + # Sem volta: reduzir as permissĂ”es do administrador sĂł criaria o problema que + # esta migration conserta. + def down; end +end -- 2.49.1 From ad1a071e4b4230f8935aa2ad854517cb407667747866e238f5132041227b058b Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 16:21:56 -0300 Subject: [PATCH 21/40] =?UTF-8?q?Corre=C3=A7=C3=A3o=20de=20horario=20e=20i?= =?UTF-8?q?mplanta=C3=A7=C3=A3o=20da=20gera=C3=A7=C3=A3o=20de=20Romaneio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + Dockerfile | 23 +- Gemfile | 9 + Gemfile.lock | 24 +- README.md | 119 +++++++ app/controllers/admin/romaneios_controller.rb | 232 +++++++++++++ .../controllers/romaneio_controller.js | 163 +++++++++ app/models/permissao.rb | 6 + app/models/romaneio.rb | 71 ++++ app/models/romaneio_linha.rb | 68 ++++ app/models/romaneio_veiculo.rb | 17 + app/models/user.rb | 1 + app/policies/romaneio_policy.rb | 19 + app/services/pdf/base_pdf.rb | 27 +- app/services/pdf/fonte_utf8.rb | 42 +++ app/services/pdf/romaneio_pdf.rb | 328 ++++++++++++++++++ app/services/romaneios/enriquecimento_gade.rb | 101 ++++++ app/services/romaneios/importador.rb | 215 ++++++++++++ app/services/romaneios/normalizador.rb | 87 +++++ app/services/romaneios/plano_do_dia.rb | 153 ++++++++ app/services/romaneios/plano_planilha.rb | 140 ++++++++ app/services/simpli_route/client.rb | 16 + app/views/admin/romaneios/_avisos.html.erb | 48 +++ app/views/admin/romaneios/_celula.html.erb | 13 + app/views/admin/romaneios/_controles.html.erb | 55 +++ app/views/admin/romaneios/_marcador.html.erb | 12 + app/views/admin/romaneios/_removidas.html.erb | 21 ++ app/views/admin/romaneios/_tabela.html.erb | 59 ++++ app/views/admin/romaneios/index.html.erb | 144 ++++++++ app/views/admin/romaneios/show.html.erb | 89 +++++ app/views/layouts/_navbar.html.erb | 6 +- bin/docker-boot | 76 ++++ bin/sondar_plano_do_dia | 149 ++++++++ config/routes.rb | 20 ++ db/migrate/20260827000001_create_romaneios.rb | 33 ++ ...20260827000002_create_romaneio_veiculos.rb | 26 ++ .../20260827000003_create_romaneio_linhas.rb | 51 +++ ...00004_add_permissao_romaneio_aos_perfis.rb | 43 +++ deploy/ntp-seguro.sh | 135 +++++++ docker-compose.yml | 42 ++- public/logo-gade.png | Bin 0 -> 14440 bytes spec/factories.rb | 33 ++ spec/models/table_names_spec.rb | 6 +- spec/policies/romaneio_policy_spec.rb | 21 ++ spec/requests/admin/romaneios_spec.rb | 82 +++++ spec/services/pdf/romaneio_pdf_spec.rb | 107 ++++++ .../romaneios/enriquecimento_gade_spec.rb | 47 +++ spec/services/romaneios/importador_spec.rb | 115 ++++++ .../services/romaneios/plano_planilha_spec.rb | 102 ++++++ spec/support/tabela_operacao.rb | 84 +++++ whatsapp/Dockerfile | 9 +- 51 files changed, 3450 insertions(+), 46 deletions(-) create mode 100644 app/controllers/admin/romaneios_controller.rb create mode 100644 app/javascript/controllers/romaneio_controller.js create mode 100644 app/models/romaneio.rb create mode 100644 app/models/romaneio_linha.rb create mode 100644 app/models/romaneio_veiculo.rb create mode 100644 app/policies/romaneio_policy.rb create mode 100644 app/services/pdf/fonte_utf8.rb create mode 100644 app/services/pdf/romaneio_pdf.rb create mode 100644 app/services/romaneios/enriquecimento_gade.rb create mode 100644 app/services/romaneios/importador.rb create mode 100644 app/services/romaneios/normalizador.rb create mode 100644 app/services/romaneios/plano_do_dia.rb create mode 100644 app/services/romaneios/plano_planilha.rb create mode 100644 app/views/admin/romaneios/_avisos.html.erb create mode 100644 app/views/admin/romaneios/_celula.html.erb create mode 100644 app/views/admin/romaneios/_controles.html.erb create mode 100644 app/views/admin/romaneios/_marcador.html.erb create mode 100644 app/views/admin/romaneios/_removidas.html.erb create mode 100644 app/views/admin/romaneios/_tabela.html.erb create mode 100644 app/views/admin/romaneios/index.html.erb create mode 100644 app/views/admin/romaneios/show.html.erb create mode 100755 bin/docker-boot create mode 100755 bin/sondar_plano_do_dia create mode 100644 db/migrate/20260827000001_create_romaneios.rb create mode 100644 db/migrate/20260827000002_create_romaneio_veiculos.rb create mode 100644 db/migrate/20260827000003_create_romaneio_linhas.rb create mode 100644 db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb create mode 100755 deploy/ntp-seguro.sh create mode 100644 public/logo-gade.png create mode 100644 spec/policies/romaneio_policy_spec.rb create mode 100644 spec/requests/admin/romaneios_spec.rb create mode 100644 spec/services/pdf/romaneio_pdf_spec.rb create mode 100644 spec/services/romaneios/enriquecimento_gade_spec.rb create mode 100644 spec/services/romaneios/importador_spec.rb create mode 100644 spec/services/romaneios/plano_planilha_spec.rb create mode 100644 spec/support/tabela_operacao.rb diff --git a/.env.example b/.env.example index 5106d4c..49b6b94 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,13 @@ # Use `development` sĂł na sua mĂĄquina local. RAILS_ENV=production +# Fuso horĂĄrio do sistema operacional dentro dos containers. +# JĂĄ Ă© o padrĂŁo no Dockerfile — sĂł mexa se a operação mudar de estado. +# ⚠ Isto define o FUSO, nĂŁo a HORA. Se a data/hora do servidor estĂĄ errada +# (atrasa, ou volta errada depois de reboot/deploy), o problema Ă© o relĂłgio +# do HOST: rode uma vez `sudo bash deploy/ntp-seguro.sh`. +TZ=America/Sao_Paulo + # ── Banco de dados (seu PostgreSQL jĂĄ existente) ───────────── # Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db diff --git a/Dockerfile b/Dockerfile index f970493..f97f83d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,22 @@ FROM ruby:3.2.2-slim +# Fuso horĂĄrio do container. +# +# POR QUE ISSO IMPORTA (caso real): a imagem sobe em UTC. O Rails atĂ© mostra a +# hora certa (`config.time_zone = "America/Sao_Paulo"`), mas TUDO que Ă© do +# sistema operacional continuava 3h adiantado — timestamp de log, `date` nos +# scripts de bin/ e, o pior, o CRON: `every "*/30 8-18"` no config/schedule.rb +# rodava das 05h Ă s 15h de BrasĂ­lia, nĂŁo das 08h Ă s 18h. +# +# O cron do Debian lĂȘ `/etc/localtime`, nĂŁo a variĂĄvel TZ — por isso o symlink +# alĂ©m do ENV. Os dois juntos cobrem processo Ruby e daemon de cron. +ENV TZ=America/Sao_Paulo +ENV DEBIAN_FRONTEND=noninteractive + # DependĂȘncias do sistema +# tzdata: explĂ­cito de propĂłsito. O zoneinfo jĂĄ vem na imagem hoje, mas se um +# rebuild futuro pegar uma base enxuta o symlink acima quebra em silĂȘncio e a +# hora volta a ficar errada — que Ă© exatamente o bug que estamos fechando. RUN apt-get update -qq && apt-get install -y \ build-essential \ libpq-dev \ @@ -10,6 +26,9 @@ RUN apt-get update -qq && apt-get install -y \ git \ libvips \ cron \ + tzdata \ + && ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \ + && echo "$TZ" > /etc/timezone \ && rm -rf /var/lib/apt/lists/* # DiretĂłrio da app @@ -27,4 +46,6 @@ COPY . . EXPOSE 3000 -CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] +# O boot real (migrations, cron, servidor) mora em bin/docker-boot — ver os +# comentĂĄrios lĂĄ. CMD aqui Ă© sĂł o fallback de quem roda a imagem sem o compose. +CMD ["bin/docker-boot"] diff --git a/Gemfile b/Gemfile index a8683a4..d69d0a2 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,11 @@ gem "prawn-table" # Planilhas (XLSX) — geração da carga de importação do SimpliRoute gem "caxlsx" +# Leitura do .xlsx do PLANO do SimpliRoute (reserva do romaneio, quando a API nĂŁo +# responde ou o plano do dia ainda nĂŁo foi publicado). A roo pina rubyzip < 3.0; +# o Ășnico outro consumidor Ă© o selenium-webdriver (grupo de teste, aceita >= 1.2.2, +# < 4.0), entĂŁo o bundler rebaixa o rubyzip sem quebrar nada. +gem "roo", "~> 2.10" gem "rqrcode" # QR code no holerite (login rĂĄpido motorista) gem "chunky_png" # renderiza QR como PNG para embed no Prawn @@ -47,6 +52,10 @@ group :development, :test do gem "rspec-rails" gem "factory_bot_rails" gem "faker" + # LĂȘ o PDF gerado (pĂĄginas e texto) nos specs. Sem ele, o Ășnico jeito de testar + # um PDF do Prawn seria olhar o arquivo: as fontes TTF sĂŁo subsetadas, entĂŁo o + # texto nĂŁo Ă© "grepĂĄvel" no binĂĄrio. + gem "pdf-inspector", require: false end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index cdfc2a2..ffa2121 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) actioncable (7.1.6) actionpack (= 7.1.6) activesupport (= 7.1.6) @@ -82,6 +83,7 @@ GEM tzinfo (~> 2.0) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) + afm (1.0.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -100,6 +102,11 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + caxlsx (4.5.0) + htmlentities (~> 4.3, >= 4.3.4) + marcel (~> 1.0) + nokogiri (~> 1.10, >= 1.10.4) + rubyzip (>= 2.4, < 4) cgi (0.5.1) chronic (0.10.2) chunky_png (1.4.0) @@ -139,6 +146,8 @@ GEM net-http (~> 0.5) globalid (1.3.0) activesupport (>= 6.1) + hashery (2.1.2) + htmlentities (4.4.2) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (2.0.2) @@ -192,6 +201,13 @@ GEM orm_adapter (0.5.0) pagy (9.4.0) pdf-core (0.10.0) + pdf-inspector (1.3.0) + pdf-reader (>= 1.0, < 3.0.a) + pdf-reader (2.16.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (>= 0.2.1, < 2) + hashery (~> 2.0) + ttfunk pg (1.6.3-x86_64-linux) pp (0.6.3) prettyprint @@ -269,6 +285,9 @@ GEM actionpack (>= 7.0) railties (>= 7.0) rexml (3.4.4) + roo (2.10.1) + nokogiri (~> 1) + rubyzip (>= 1.3.0, < 3.0.0) rqrcode (3.2.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) @@ -290,7 +309,7 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.7) - rubyzip (3.4.0) + rubyzip (2.4.1) securerandom (0.4.1) selenium-webdriver (4.44.0) base64 (~> 0.2) @@ -344,6 +363,7 @@ PLATFORMS DEPENDENCIES annotate capybara + caxlsx chunky_png debug devise @@ -354,6 +374,7 @@ DEPENDENCIES importmap-rails jbuilder pagy (~> 9.0) + pdf-inspector pg (~> 1.1) prawn prawn-table @@ -362,6 +383,7 @@ DEPENDENCIES pundit rack-mini-profiler rails (~> 7.1.0) + roo (~> 2.10) rqrcode rspec-rails selenium-webdriver diff --git a/README.md b/README.md index eccea02..9036554 100644 --- a/README.md +++ b/README.md @@ -3068,3 +3068,122 @@ caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocass celular, onde nĂŁo hĂĄ faixa lateral livre. + +--- + +
+🕐 RelĂłgio, restart automĂĄtico e migrations no boot + +## O problema relatado + +> "toda vez que faço o deploy ou reinicio o Docker a data e hora ficam erradas." + +SĂŁo **dois problemas diferentes** que se confundem porque aparecem no mesmo lugar: + +| Sintoma | Causa | Onde se resolve | +|---|---|---| +| Hora **sempre 3h adiantada**, todo restart | O container subia em **UTC** | `Dockerfile` (fuso) | +| Hora **derivando / errada depois de reboot** | RelĂłgio do **host** sem sincronia | `deploy/ntp-seguro.sh` (NTS) | + +### 1. Fuso do container (o "sempre 3h Ă  frente") + +A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails atĂ© mostrava a hora certa +(`config.time_zone = "America/Sao_Paulo"`), mas **tudo que era do sistema +operacional continuava adiantado** — e o pior caso nĂŁo era visual: + +> **O cron rodava no horĂĄrio errado.** `every "*/30 8-18"` no `config/schedule.rb` +> executava das **05h Ă s 15h** de BrasĂ­lia, nĂŁo das 08h Ă s 18h. + +Agora o `Dockerfile` fixa `TZ=America/Sao_Paulo` **e** o symlink de +`/etc/localtime` — os dois, porque o cron do Debian lĂȘ o arquivo, nĂŁo a +variĂĄvel. O mesmo foi feito no `whatsapp/Dockerfile` (Alpine precisa do pacote +`tzdata`, que nĂŁo vem na imagem). DĂĄ para ajustar sem rebuild pelo `TZ` do `.env`. + +### 2. Hora do host — NTP autenticado (NTS) + +**NĂŁo dĂĄ para sincronizar o relĂłgio de dentro do container**: ele lĂȘ o clock do +kernel do host. Rodar NTP lĂĄ dentro exigiria `CAP_SYS_TIME` e mudaria a hora do +servidor inteiro a partir de um processo da aplicação. + +No servidor, uma vez: + +```bash +sudo bash deploy/ntp-seguro.sh +``` + +O script instala e configura o **chrony com NTS** (RFC 8915): a troca de chaves +Ă© por TLS (TCP 4460) e os pacotes NTP vĂȘm assinados, entĂŁo uma resposta forjada +no caminho Ă© descartada. NTP comum Ă© UDP sem autenticação — e hora errada aqui +nĂŁo Ă© detalhe: ela muda **o dia da consolidação, o recorte do perĂ­odo financeiro +e a validade da sessĂŁo**. + +O script ainda: usa **3 fontes independentes** (Cloudflare, Netnod, PTB), +**desliga as fontes nĂŁo autenticadas** (deixar o `pool` padrĂŁo anularia o ganho), +desativa o `systemd-timesyncd` (nĂŁo fala NTS e brigaria pelo relĂłgio), liga o +`rtcsync` (Ă© o relĂłgio de hardware que dĂĄ a hora no boot) e mostra a +conferĂȘncia no fim. É idempotente e guarda `.bak` da config. + +Conferir depois, a qualquer momento: + +```bash +chronyc tracking # 'System time' deve ficar na casa dos milissegundos +chronyc -N authdata # NTS ativo em cada fonte (Cook > 0) +``` + +> **Firewall:** precisa de saĂ­da em **UDP 123** e **TCP 4460**. Sem a 4460 o NTS +> nĂŁo fecha e o chrony fica sem fonte. + +## Restart automĂĄtico + migrations + +`docker-compose.yml`, serviço `app`: **`restart: unless-stopped`**. Sobe sozinho +depois de queda do processo e de reboot do servidor, e sĂł fica parado se alguĂ©m +der `docker compose stop/down`. (`always` foi descartado: ele reergue o container +atĂ© depois de um `stop` deliberado, tirando do operador a chance de deixar o +sistema fora do ar de propĂłsito.) + +O boot saiu do `command:` de uma linha sĂł e virou **`bin/docker-boot`**, com o +motivo de cada passo comentado: + +1. **relĂłgio** — imprime a hora e o fuso no log (se aparecer UTC, o rebuild nĂŁo pegou); +2. **pid ĂłrfĂŁo** — sem limpar, o Puma se recusa a subir depois de uma queda; +3. **gems** — `bundle check || bundle install` (o volume `bundle_cache` sombreia os gems da imagem); +4. **migrations** — `db:prepare`, **com espera pelo banco**; +5. **cron** — `whenever --update-crontab` + daemon, *best-effort*; +6. **Puma**. + +Ou seja: **a migration Ă© automĂĄtica a todo restart** — `db:prepare` cria o banco +se nĂŁo existir, aplica as migrations pendentes e sĂł faz seed em banco novo +(idempotente). NĂŁo hĂĄ passo manual depois do deploy. + +> **Por que a espera no passo 4:** o PostgreSQL Ă© externo ao compose. Num reboot +> do servidor o Rails pode subir antes de o banco aceitar conexĂŁo — e agora, com +> `restart: unless-stopped`, isso viraria um **loop de reinĂ­cio** parecendo erro +> de migration. SĂŁo 10 tentativas × 6s. Se falhar depois disso (migration +> quebrada, credencial errada), o container sai com erro **de propĂłsito**: melhor +> do que servir a aplicação contra um schema desatualizado. + +## Deploy + +```bash +# 1. No servidor, uma Ășnica vez — relĂłgio com NTP autenticado: +sudo bash deploy/ntp-seguro.sh + +# 2. Rebuild obrigatĂłrio (o fuso entra na imagem): +docker compose up -d --build + +# 3. Conferir no log que o container estĂĄ em -03 e nĂŁo em UTC: +docker compose logs app | grep '\[boot\] relĂłgio' +``` + +## 📂 Arquivos + +``` +Dockerfile # TZ=America/Sao_Paulo + tzdata + /etc/localtime; CMD -> bin/docker-boot +whatsapp/Dockerfile # tzdata (Alpine) + mesmo fuso +docker-compose.yml # restart: unless-stopped, TZ, command: bin/docker-boot +bin/docker-boot # boot documentado: migrations com espera, cron, Puma — NOVO +deploy/ntp-seguro.sh # chrony + NTS no host (rodar 1x, como root) — NOVO +.env.example # TZ=America/Sao_Paulo + aviso "fuso ≠ hora" +``` + +
diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb new file mode 100644 index 0000000..cd0dfbb --- /dev/null +++ b/app/controllers/admin/romaneios_controller.rb @@ -0,0 +1,232 @@ +# app/controllers/admin/romaneios_controller.rb +# +# Romaneio "CONTROLE DE ENTREGA": importa o plano do dia, deixa o operador revisar a +# tabela com a prĂ©via do PDF real ao lado, e imprime uma folha A4 paisagem por +# veĂ­culo. +# +# As bases externas (espelho de rastreio e gade_entregas_*) continuam SOMENTE +# LEITURA — o que gravamos sĂŁo as tabelas `romaneio*`, nossas. +class Admin::RomaneiosController < ApplicationController + include Auditavel + + before_action :carregar_romaneio, except: %i[index create importar_planilha] + + def index + authorize Romaneio + # includes: a listagem mostra a contagem de veĂ­culos de cada romaneio. + @romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50) + @operacoes_agrupadas = Operacao.agrupadas_por_mes + @data_padrao = Date.current + end + + def show + authorize @romaneio + @veiculos = @romaneio.romaneio_veiculos.ordenados.to_a + @veiculo = veiculo_selecionado + @linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo + @removidas = @romaneio.linhas_removidas.to_a + @colunas_ausentes = colunas_ausentes_da_operacao + end + + # Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho). + def create + authorize Romaneio + @romaneio = preparar_romaneio + return if performed? + + plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date) + importar(plano.linhas, origem: plano.origem) + rescue SimpliRoute::Error => e + # NotFound herda de Error: a mensagem dele jĂĄ diz para enviar o .xlsx. + descartar_romaneio_vazio + redirect_to admin_romaneios_path, alert: e.message + end + + # Caminho B: upload do .xlsx do plano, para quando o plano do dia ainda nĂŁo foi + # publicado ou a API nĂŁo responde. + def importar_planilha + authorize Romaneio + @romaneio = preparar_romaneio + return if performed? + + arquivo = params[:arquivo] + if arquivo.blank? + descartar_romaneio_vazio + return redirect_to(admin_romaneios_path, alert: 'Selecione a planilha do plano (.xlsx).') + end + + importar(Romaneios::PlanoPlanilha.new(arquivo).linhas, origem: 'planilha') + rescue Romaneios::PlanoPlanilha::Erro => e + descartar_romaneio_vazio + redirect_to admin_romaneios_path, alert: e.message + end + + # Rebusca o plano e reconcilia com o que jĂĄ estĂĄ editado (a regra estĂĄ em + # Romaneios::Importador). + def reimportar + authorize @romaneio + + # Reimportar com arquivo em mĂŁos usa o arquivo; sem arquivo, volta ao + # SimpliRoute — inclusive para um romaneio que nasceu de planilha (Ă© justamente + # assim que o operador troca o plano B pelo A quando a API volta). + if params[:arquivo].present? + plano = Romaneios::PlanoPlanilha.new(params[:arquivo]) + linhas = plano.linhas + origem = 'planilha' + else + plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date) + linhas = plano.linhas + origem = plano.origem + end + + resultado = aplicar(linhas, origem: origem) + auditar!(:reimportar, @romaneio, dados_novos: resultado.to_h) + redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), + notice: "Plano reimportado — #{resultado.resumo}." + rescue SimpliRoute::Error, Romaneios::PlanoPlanilha::Erro => e + redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: e.message + end + + # Com ?veiculo= sai INLINE (Ă© a prĂ©via do editor, uma folha sĂł, sub-100ms); + # sem veiculo sai o romaneio inteiro como download. + def pdf + authorize @romaneio + + veiculo = params[:veiculo].to_s + binario = Pdf::RomaneioPdf.new(romaneio: @romaneio, + veiculos: veiculo.presence && [veiculo]).render + + # A prĂ©via Ă© regerada a cada edição: cache do navegador aqui devolveria o PDF + # anterior e o operador acharia que a alteração nĂŁo gravou. + expires_now if veiculo.present? + + send_data binario, type: 'application/pdf', + disposition: veiculo.present? ? 'inline' : 'attachment', + filename: nome_do_arquivo(veiculo) + end + + # JSON — grava UM campo de UMA parada. `valor: null` restaura o que o plano + # trouxe (e destrava o campo para a prĂłxima reimportação). + def atualizar_linha + authorize @romaneio + + linha = @romaneio.romaneio_linhas.find(params[:linha_id]) + campo = params[:campo].to_s + unless RomaneioLinha::CAMPOS_EDITAVEIS.include?(campo) + return render json: { ok: false, erro: 'Campo nĂŁo editĂĄvel.' }, status: :unprocessable_entity + end + + if params[:valor].nil? + linha[campo] = linha.valor_do_plano(campo) + linha.desmarcar_editado!(campo) + else + linha[campo] = params[:valor].to_s + linha.marcar_editado!(campo) + end + linha.save! + + # Devolvemos o valor GRAVADO e o JS o reescreve no input: a gravação Ă© + # last-write-wins, e assim uma sobreposição de duas abas fica visĂ­vel em vez + # de silenciosa. + render json: { ok: true, valor: linha[campo], editado: linha.editado?(campo) } + end + + def atualizar_veiculo + authorize @romaneio + + registro = @romaneio.romaneio_veiculos.find_by!(veiculo: params[:veiculo].to_s) + registro.update!(motorista: params[:motorista].to_s) + render json: { ok: true, valor: registro.motorista } + end + + def atualizar_rotulo + authorize @romaneio + + @romaneio.update!(rotulo_plano: params[:rotulo_plano].to_s) + render json: { ok: true, valor: @romaneio.rotulo_plano } + end + + def destroy + authorize @romaneio + + # Auditar ANTES: depois do destroy nĂŁo hĂĄ mais atributos para registrar. + auditar!(:excluir, @romaneio, dados_anteriores: @romaneio.attributes) + @romaneio.destroy! + redirect_to admin_romaneios_path, notice: 'Romaneio excluĂ­do.' + end + + private + + def carregar_romaneio + @romaneio = Romaneio.find(params[:id]) + end + + # Quais colunas a operação NÃO tem. Perguntamos ao serviço em vez de deduzir de + # "a coluna APARELHO saiu toda vazia": vazio tambĂ©m Ă© o resultado legĂ­timo de uma + # operação sĂł com pacientes recorrentes, e um aviso que erra o motivo Ă© pior do + # que nenhum. + def colunas_ausentes_da_operacao + return [] if @romaneio.operacao_tabela.blank? + + Romaneios::EnriquecimentoGade.new(@romaneio.operacao_tabela).colunas_ausentes + rescue ArgumentError + # Operação removida do banco depois que o romaneio foi criado. + Romaneios::EnriquecimentoGade::COLUNAS.keys + end + + # Acha (ou cria) o romaneio da data + operação. `find_or_create_by!` Ă© o que faz + # "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo + # romaneio — a chave Ășnica (planned_date, operacao_tabela) Ă© a garantia disso. + def preparar_romaneio + data = params[:planned_date].presence + if data.blank? + redirect_to admin_romaneios_path, alert: 'Informe a data do plano.' + return nil + end + + tabela = params[:operacao_tabela].to_s + if tabela.present? && !Operacao.valida?(tabela) + redirect_to admin_romaneios_path, alert: 'Selecione uma operação vĂĄlida.' + return nil + end + + data = Date.parse(data) + Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r| + r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data) + r.importado_por = current_user + end + rescue Date::Error + redirect_to admin_romaneios_path, alert: 'Data do plano invĂĄlida.' + nil + end + + def importar(linhas, origem:) + resultado = aplicar(linhas, origem: origem) + auditar!(:criar, @romaneio, dados_novos: resultado.to_h) + redirect_to admin_romaneio_path(@romaneio), + notice: "Plano importado — #{resultado.resumo}." + end + + def aplicar(linhas, origem:) + Romaneios::Importador.new(romaneio: @romaneio, linhas: linhas, + usuario: current_user, origem: origem).aplicar! + end + + # Um romaneio recĂ©m-criado que nĂŁo chegou a receber nenhuma parada Ă© lixo: some + # da lista para o operador nĂŁo achar que a importação funcionou. + def descartar_romaneio_vazio + @romaneio&.destroy! if @romaneio&.persisted? && @romaneio.romaneio_linhas.none? + end + + def veiculo_selecionado + pedido = params[:veiculo].to_s + return pedido if @veiculos.any? { |v| v.veiculo == pedido } + + @veiculos.first&.veiculo + end + + def nome_do_arquivo(veiculo) + partes = ['romaneio', @romaneio.rotulo_plano.presence || @romaneio.planned_date.to_s, veiculo] + "#{partes.compact_blank.join('_').parameterize(separator: '_')}.pdf" + end +end diff --git a/app/javascript/controllers/romaneio_controller.js b/app/javascript/controllers/romaneio_controller.js new file mode 100644 index 0000000..d8b723f --- /dev/null +++ b/app/javascript/controllers/romaneio_controller.js @@ -0,0 +1,163 @@ +import { Controller } from "@hotwired/stimulus" + +// Editor do romaneio: salva cada campo assim que ele muda e reatualiza a prĂ©via +// (que Ă© o PDF de verdade, num iframe). +// +// Por que salvar POR CAMPO e nĂŁo com um submit de formulĂĄrio: `campos_editados` +// precisa registrar exatamente o campo que um humano tocou. Um submit em bloco nĂŁo +// distingue "redigitei o mesmo valor" de "nunca olhei" — marcaria os 5 campos das 29 +// linhas como editados, e a reimportação seguinte preservaria endereços velhos no +// plano inteiro. A regra de reconciliação (Romaneios::Importador) sĂł funciona com +// escrita por campo. +export default class extends Controller { + static targets = ["celula", "preview", "veiculo", "motorista", "rotulo", "status", "linha"] + static values = { url: String, pdfUrl: String, veiculo: String } + + // Recarregar o PDF a cada tecla travaria a tela; 600ms depois do Ășltimo save Ă© o + // suficiente para parecer imediato sem regerar o documento Ă  toa. + static ATRASO_PREVIEW = 600 + + connect() { + this.timerPreview = null + } + + disconnect() { + if (this.timerPreview) clearTimeout(this.timerPreview) + } + + // ── Edição ────────────────────────────────────────────────────────────── + salvarCelula(event) { + const input = event.currentTarget + // Sair do campo sem mudar nada nĂŁo Ă© edição — marcar como editado aqui + // congelaria o valor contra a prĂłxima reimportação sem o operador querer. + if (input.value === input.dataset.original) return + + this.gravar(input.dataset.linhaId, input.dataset.campo, input.value, input) + } + + restaurar(event) { + const botao = event.currentTarget + const { linhaId, campo } = botao.dataset + const input = this.celulaTargets.find( + (c) => c.dataset.linhaId === linhaId && c.dataset.campo === campo + ) + // valor null no corpo = "devolve o que o plano trouxe e destrava o campo" + this.gravar(linhaId, campo, null, input, botao) + } + + alternarAparelho(event) { + const botao = event.currentTarget + const novo = botao.dataset.valor === "SIM" ? "" : "SIM" + this.gravar(botao.dataset.linhaId, "aparelho", novo, null, null, (dados) => { + botao.dataset.valor = dados.valor + botao.textContent = dados.valor || "—" + const estilo = dados.valor === "SIM" + ? "bg-green-600/20 text-green-300 border-green-600/40" + : "bg-[#1a1a1a] text-gray-500 border-white/10" + botao.className = `${estilo} w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors` + this.marcar(botao.dataset.linhaId, "aparelho", dados.editado) + }) + } + + salvarRotulo() { + this.enviar(`${this.urlValue}/atualizar_rotulo`, { rotulo_plano: this.rotuloTarget.value }) + .then(() => this.agendarPreview()) + } + + salvarMotorista() { + // NĂŁo mexe na prĂ©via: o nome do motorista nĂŁo sai impresso — a linha do + // rodapĂ© Ă© preenchida Ă  mĂŁo na retirada. + this.enviar(`${this.urlValue}/atualizar_veiculo`, { + veiculo: this.veiculoValue, + motorista: this.motoristaTarget.value + }) + } + + // ── Navegação e filtro ────────────────────────────────────────────────── + trocarVeiculo() { + window.location.search = `?veiculo=${encodeURIComponent(this.veiculoTarget.value)}` + } + + anterior() { this.mover(-1) } + proximo() { this.mover(1) } + + mover(passo) { + const select = this.veiculoTarget + const destino = select.selectedIndex + passo + if (destino < 0 || destino >= select.options.length) return + select.selectedIndex = destino + this.trocarVeiculo() + } + + filtrar(event) { + const termo = event.currentTarget.value.trim().toLowerCase() + this.linhaTargets.forEach((linha) => { + linha.hidden = termo !== "" && !linha.dataset.busca.includes(termo) + }) + } + + atualizarPreview() { + if (!this.hasPreviewTarget) return + // Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do + // navegador e a tela pisca a cada salvamento. + this.previewTarget.src = + `${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}` + } + + // ── Infra ─────────────────────────────────────────────────────────────── + gravar(linhaId, campo, valor, input, botao, aoConcluir) { + this.status("Salvando
") + + this.enviar(`${this.urlValue}/atualizar_linha`, { linha_id: linhaId, campo, valor }) + .then((dados) => { + if (!dados || !dados.ok) return + if (input) { + // Escrevemos de volta o valor QUE O SERVIDOR GRAVOU: a escrita Ă© + // last-write-wins, e assim uma sobreposição de duas abas fica visĂ­vel em + // vez de silenciosa. + input.value = dados.valor + input.dataset.original = dados.valor + } + this.marcar(linhaId, campo, dados.editado) + if (aoConcluir) aoConcluir(dados) + this.status("Salvo") + this.agendarPreview() + }) + .catch(() => this.status("NĂŁo deu para salvar — tente de novo")) + } + + marcar(linhaId, campo, editado) { + const alvo = this.element.querySelector(`[data-romaneio-marcador="${linhaId}-${campo}"]`) + if (!alvo) return + + if (!editado) { + alvo.innerHTML = "" + return + } + alvo.innerHTML = + `` + } + + agendarPreview() { + if (this.timerPreview) clearTimeout(this.timerPreview) + this.timerPreview = setTimeout(() => this.atualizarPreview(), this.constructor.ATRASO_PREVIEW) + } + + enviar(url, corpo) { + return fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content + }, + body: JSON.stringify(corpo) + }).then((r) => (r.ok ? r.json() : Promise.reject(r))) + } + + status(texto) { + if (this.hasStatusTarget) this.statusTarget.textContent = texto + } +} diff --git a/app/models/permissao.rb b/app/models/permissao.rb index ca9833b..4571c8a 100644 --- a/app/models/permissao.rb +++ b/app/models/permissao.rb @@ -166,6 +166,11 @@ module Permissao 'admin.planilha_simpli_route' => { grupo: 'admin', label: 'Gerar planilha de carga', descricao: 'Download do .xlsx de importação do SimpliRoute.' + }, + 'admin.romaneio' => { + grupo: 'admin', label: 'Gerar romaneio de entrega', + descricao: 'Importa o plano do dia, edita a tabela e imprime o "CONTROLE DE ' \ + 'ENTREGA" que o motorista assina ao receber a carga.' } }.freeze @@ -189,6 +194,7 @@ module Permissao consolidacao.gerir_motoristas consolidacao.exportar_pdf notificacao.contatos notificacao.contatos_gerenciar notificacao.envios admin.usuarios admin.configuracoes admin.auditoria admin.planilha_simpli_route + admin.romaneio ].freeze, 'operador' => %w[ dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas diff --git a/app/models/romaneio.rb b/app/models/romaneio.rb new file mode 100644 index 0000000..db46b81 --- /dev/null +++ b/app/models/romaneio.rb @@ -0,0 +1,71 @@ +# app/models/romaneio.rb +# +# Um plano do SimpliRoute importado para virar o "CONTROLE DE ENTREGA" — a folha +# que o motorista assina ao retirar a carga, uma pĂĄgina A4 paisagem por veĂ­culo. +# +# O romaneio Ă© NOSSO (tabela gravĂĄvel). As fontes continuam somente leitura: o +# espelho de rastreio (Entrega) e as tabelas de operação (gade_entregas_*). +class Romaneio < ApplicationRecord + ORIGENS = %w[api espelho planilha].freeze + + # MĂĄximo de paradas que cabem numa folha. O nĂșmero Ă© da GEOMETRIA do PDF, entĂŁo a + # fonte Ă© Pdf::RomaneioPdf — duplicar aqui criaria duas verdades, e o dia em que + # alguĂ©m mexesse na margem a tela passaria a avisar "sai em 1 folha" para um + # romaneio que sai em 2. Lido em tempo de chamada (e nĂŁo numa constante) para nĂŁo + # amarrar a ordem de carga dos autoloads. + def self.linhas_por_folha + Pdf::RomaneioPdf::MAX_LINHAS + end + + belongs_to :importado_por, class_name: 'User', optional: true + + has_many :romaneio_veiculos, -> { order(:posicao, :veiculo) }, + dependent: :destroy, inverse_of: :romaneio + has_many :romaneio_linhas, dependent: :destroy, inverse_of: :romaneio + + validates :planned_date, presence: true + validates :origem, inclusion: { in: ORIGENS } + validates :planned_date, uniqueness: { scope: :operacao_tabela } + + scope :recentes, -> { order(planned_date: :desc, id: :desc) } + + # Linhas que entram no PDF: as que continuam no plano, na ordem do roteiro. + # `removido_em` marca a parada que saiu do plano mas tinha edição humana — ela + # fica visĂ­vel na tela para o operador decidir, e NUNCA sai impressa. + def linhas_vigentes + romaneio_linhas.where(removido_em: nil).order(:veiculo, :ordem, :id) + end + + def linhas_removidas + romaneio_linhas.where.not(removido_em: nil).order(:veiculo, :ordem, :id) + end + + # { 'GADE_001' => 29, ... } — alimenta o seletor de veĂ­culo e o aviso de folhas. + def contagem_por_veiculo + @contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count + end + + def folhas_de(veiculo) + total = contagem_por_veiculo[veiculo].to_i + return 1 if total.zero? + + (total.to_f / self.class.linhas_por_folha).ceil + end + + # VeĂ­culos que estouram uma folha — a tela avisa antes de imprimir, porque + # descobrir isso sĂł no papel Ă© descobrir tarde. + def veiculos_multifolha + contagem_por_veiculo.select { |_v, n| n > self.class.linhas_por_folha } + end + + def operacao_label + operacao_tabela.present? ? Operacao.label(operacao_tabela) : nil + end + + # RĂłtulo do canto direito do PDF ("EMAD 09.2026"). SĂł uma SUGESTÃO: nĂŁo existe + # fonte canĂŽnica para esse texto, entĂŁo ele Ă© editĂĄvel na tela. + def self.rotulo_sugerido(tabela, data) + base = tabela.present? ? Operacao.label(tabela).split.first : nil + [base, data.to_date.strftime('%m.%Y')].compact.join(' ') + end +end diff --git a/app/models/romaneio_linha.rb b/app/models/romaneio_linha.rb new file mode 100644 index 0000000..153207b --- /dev/null +++ b/app/models/romaneio_linha.rb @@ -0,0 +1,68 @@ +# app/models/romaneio_linha.rb +# +# Uma PARADA do romaneio — vira uma linha da tabela do PDF. +# +# ⚠ Nome de tabela fixado: "RomaneioLinha".tableize jĂĄ dĂĄ `romaneio_linhas`, mas a +# declaração explĂ­cita Ă© a trava contra alguĂ©m acrescentar um irregular ao +# config/initializers/inflections.rb e mudar isso sem perceber (ConsolidacaoMotorista +# jĂĄ quebrou /motorista em produção exatamente assim). Coberto por +# spec/models/table_names_spec.rb. +class RomaneioLinha < ApplicationRecord + self.table_name = 'romaneio_linhas' + + # Os Ășnicos campos que o operador edita — e a whitelist que o controller usa + # para nĂŁo deixar um PATCH escrever em `veiculo`, `ordem` ou `chave_origem`. + # + # `veiculo` e `ordem` NÃO sĂŁo editĂĄveis de propĂłsito: mover uma parada de veĂ­culo + # aqui produziria um romaneio que contradiz a rota que o motorista recebe no + # celular — dois papĂ©is, mesma entrega, respostas diferentes. VeĂ­culo errado se + # corrige no SimpliRoute e se reimporta. + CAMPOS_EDITAVEIS = %w[nota_fiscal nome endereco aparelho telefones].freeze + + belongs_to :romaneio, inverse_of: :romaneio_linhas + + validates :veiculo, :chave_origem, presence: true + + scope :vigentes, -> { where(removido_em: nil) } + scope :removidas, -> { where.not(removido_em: nil) } + scope :do_veiculo, ->(veiculo) { where(veiculo: veiculo) } + + # Identidade ESTÁVEL entre reimportaçÔes. A NF quando existe; senĂŁo um digest do + # tĂ­tulo + endereço, porque nem toda linha do plano casa "NF 90507 - FULANO" e + # duas linhas sem NF colidiriam no Ă­ndice Ășnico, derrubando a importação inteira. + # + # VeĂ­culo e ordem ficam FORA da chave: mudam quando o plano Ă© reotimizado e a + # parada continua sendo a mesma entrega. + def self.chave_para(nota_fiscal:, titulo: nil, endereco: nil) + nf = nota_fiscal.to_s.strip + return nf if nf.present? + + "T:#{Digest::MD5.hexdigest("#{titulo}|#{endereco}")}" + end + + def editado?(campo) + campos_editados.include?(campo.to_s) + end + + def editada? + campos_editados.present? + end + + # Valor que o PLANO trouxe na Ășltima importação — Ă© o que o botĂŁo "restaurar" + # devolve, e o que a reconciliação compara. + def valor_do_plano(campo) + origem[campo.to_s].to_s + end + + # Marca o campo como tocado por humano. É ISTO que sobrevive Ă  reimportação: + # o dirty tracking Ă© POR CAMPO, nĂŁo por linha — "corrigi o telefone" nĂŁo pode + # significar "tambĂ©m respondo pelo endereço" e congelar um endereço velho quando + # o SimpliRoute regeocodificar. + def marcar_editado!(campo) + self.campos_editados = (campos_editados + [campo.to_s]).uniq + end + + def desmarcar_editado!(campo) + self.campos_editados = campos_editados - [campo.to_s] + end +end diff --git a/app/models/romaneio_veiculo.rb b/app/models/romaneio_veiculo.rb new file mode 100644 index 0000000..e674ea1 --- /dev/null +++ b/app/models/romaneio_veiculo.rb @@ -0,0 +1,17 @@ +# app/models/romaneio_veiculo.rb +# +# Uma FOLHA do romaneio: um veĂ­culo, uma pĂĄgina (ou mais, quando passa de +# Romaneio::LINHAS_POR_FOLHA paradas). +# +# ⚠ `motorista` NÃO Ă© impresso. O rodapĂ© do documento traz "MOTORISTA: ______" +# para preenchimento Ă  mĂŁo — Ă© assim que a folha Ă© assinada hoje, e o programa +# Python que originou esta tela tambĂ©m coletava o nome sem usĂĄ-lo. O campo existe +# para o operador saber, na tela, de quem Ă© cada carro enquanto revisa. +class RomaneioVeiculo < ApplicationRecord + belongs_to :romaneio, inverse_of: :romaneio_veiculos + + validates :veiculo, presence: true + validates :veiculo, uniqueness: { scope: :romaneio_id } + + scope :ordenados, -> { order(:posicao, :veiculo) } +end diff --git a/app/models/user.rb b/app/models/user.rb index be5d55c..967d352 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -129,6 +129,7 @@ class User < ApplicationRecord ['admin.usuarios', :admin_usuarios_path], ['admin.perfis', :admin_perfis_path], ['admin.configuracoes', :admin_configuracoes_path], + ['admin.romaneio', :admin_romaneios_path], ['notificacao.contatos', :admin_contatos_path], ['notificacao.eventos', :admin_eventos_path], ['notificacao.envios', :admin_envios_path] diff --git a/app/policies/romaneio_policy.rb b/app/policies/romaneio_policy.rb new file mode 100644 index 0000000..59faf07 --- /dev/null +++ b/app/policies/romaneio_policy.rb @@ -0,0 +1,19 @@ +# app/policies/romaneio_policy.rb +# +# Uma chave por TELA, como manda o comentĂĄrio de Permissao: importar o plano, editar +# a tabela e imprimir sĂŁo a MESMA operação do ponto de vista de quem carrega o +# caminhĂŁo — quebrar mais fino encheria a tela de perfil de caixinhas que ninguĂ©m +# sabe responder. +class RomaneioPolicy < ApplicationPolicy + def index? = pode?('admin.romaneio') + def show? = index? + def create? = index? + def destroy? = index? + + def importar_planilha? = index? + def reimportar? = index? + def pdf? = index? + def atualizar_linha? = index? + def atualizar_veiculo? = index? + def atualizar_rotulo? = index? +end diff --git a/app/services/pdf/base_pdf.rb b/app/services/pdf/base_pdf.rb index d576cfc..c5ec729 100644 --- a/app/services/pdf/base_pdf.rb +++ b/app/services/pdf/base_pdf.rb @@ -9,16 +9,15 @@ module Pdf PRETO = '0A0A0A' CINZA = '6B7280' - # Fonte TrueType com suporte total a UTF-8. As fontes embutidas do Prawn - # (AFM/Helvetica) sĂł aceitam o charset Windows-1252 e estouram - # Prawn::Errors::IncompatibleStringEncoding em caracteres como "✓". - FONTS_DIR = Rails.root.join('app', 'assets', 'fonts') + # Fonte TrueType com suporte total a UTF-8 — o porquĂȘ estĂĄ em Pdf::FonteUtf8, + # que tambĂ©m serve o romaneio (paisagem, marca da GADE, sem herdar daqui). + include Pdf::FonteUtf8 def initialize # Margem inferior folgada (72) para o rodapĂ© repetido (desenhado em y=20) # nĂŁo sobrepor o conteĂșdo. @pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 72, 40]) - registrar_fonte_utf8 + registrar_fonte_utf8(@pdf) end def render @@ -30,24 +29,6 @@ module Pdf private - # Registra a famĂ­lia DejaVu Sans (TTF) e a define como fonte padrĂŁo. - # Como os PDFs sĂł usam normal e :bold, mapeamos italic/bold_italic para - # as variantes existentes, evitando erro caso :italic seja solicitado. - def registrar_fonte_utf8 - normal = FONTS_DIR.join('DejaVuSans.ttf').to_s - bold = FONTS_DIR.join('DejaVuSans-Bold.ttf').to_s - - @pdf.font_families.update( - 'DejaVu' => { - normal: normal, - bold: bold, - italic: normal, - bold_italic: bold - } - ) - @pdf.font 'DejaVu' - end - def cabecalho @pdf.fill_color PRETO @pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70 diff --git a/app/services/pdf/fonte_utf8.rb b/app/services/pdf/fonte_utf8.rb new file mode 100644 index 0000000..2d87050 --- /dev/null +++ b/app/services/pdf/fonte_utf8.rb @@ -0,0 +1,42 @@ +# app/services/pdf/fonte_utf8.rb +# +# Registro da famĂ­lia DejaVu Sans (TrueType) num Prawn::Document. +# +# Por que existe: as fontes embutidas do Prawn (AFM/Helvetica) sĂł aceitam o charset +# Windows-1252 e estouram Prawn::Errors::IncompatibleStringEncoding em qualquer +# caractere fora dele ("✓" no extrato do motorista; e, no romaneio, endereços que +# chegam do cliente com "SÃO PAULO", "INDAIÁ", "JARDIM MIRIAM"). +# +# Estava privado em Pdf::BasePdf, mas o motivo nĂŁo tem nada a ver com o layout +# retrato/preto-laranja da Reem — vale para todo PDF do app. Pdf::RomaneioPdf Ă© +# paisagem com a marca da GADE e nĂŁo herda de BasePdf, entĂŁo precisa do mesmo +# registro sem arrastar o cabeçalho junto. +module Pdf + module FonteUtf8 + # Resolvido em tempo de CHAMADA, nĂŁo de carga: Pdf::RomaneioPdf precisa ser + # `require`-ĂĄvel num container Ruby pelado (sem Rails) para a conferĂȘncia de + # layout em PNG — Ă© o Ășnico jeito de validar PDF neste repositĂłrio, que nĂŁo tem + # Ruby local. Uma constante com Rails.root no corpo do mĂłdulo mataria isso. + def self.dir_padrao + if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + Rails.root.join('app', 'assets', 'fonts').to_s + else + File.expand_path('../../assets/fonts', __dir__) + end + end + + # Como os PDFs sĂł usam normal e :bold, mapeamos italic/bold_italic para as + # variantes existentes — assim um :italic pedido por engano nĂŁo levanta erro. + def registrar_fonte_utf8(documento, dir = Pdf::FonteUtf8.dir_padrao) + documento.font_families.update( + 'DejaVu' => { + normal: File.join(dir.to_s, 'DejaVuSans.ttf'), + bold: File.join(dir.to_s, 'DejaVuSans-Bold.ttf'), + italic: File.join(dir.to_s, 'DejaVuSans.ttf'), + bold_italic: File.join(dir.to_s, 'DejaVuSans-Bold.ttf') + } + ) + documento.font 'DejaVu' + end + end +end diff --git a/app/services/pdf/romaneio_pdf.rb b/app/services/pdf/romaneio_pdf.rb new file mode 100644 index 0000000..7fb43e4 --- /dev/null +++ b/app/services/pdf/romaneio_pdf.rb @@ -0,0 +1,328 @@ +# app/services/pdf/romaneio_pdf.rb +# +# "CONTROLE DE ENTREGA" — a folha que o motorista assina ao retirar a carga. +# Uma pĂĄgina A4 PAISAGEM por veĂ­culo, no layout que a GADE jĂĄ recebe hoje +# (referĂȘncia: Imagens para implantação/Romaneiro_EMAD_SETEMBRO_2026.pdf). +# +# NÃO herda de Pdf::BasePdf de propĂłsito: aquela classe Ă© retrato, chumba o +# cabeçalho preto/laranja da REEM e instala um rodapĂ© cinza repetido em todas as +# pĂĄginas — os trĂȘs errados aqui. Compartilham sĂł Pdf::FonteUtf8. +# +# ── Por que desenho manual e nĂŁo prawn-table ──────────────────────────────── +# A geometria aqui Ă© RÍGIDA: altura de linha exata, sem quebra de texto, fonte que +# encolhe por cĂ©lula atĂ© caber. No prawn-table, `height` Ă© PISO e nĂŁo valor exato — +# uma cĂ©lula que quebrasse em duas linhas empurraria a tabela alĂ©m de bounds.height +# e o prawn-table dividiria em outra pĂĄgina, quebrando "uma folha por veĂ­culo" e +# dessincronizando o cabeçalho (Ă© exatamente o bug latente do programa Python que +# originou esta tela: o contador de pĂĄgina avançava por PÁGINA, entĂŁo um veĂ­culo de +# duas pĂĄginas carimbava o veĂ­culo errado na segunda e em todas as seguintes). +# O Prawn nativo resolve com `overflow: :shrink_to_fit, single_line: true` numa +# caixa de altura fixa: depois que o corpo trava em MIN_FONTE o texto CORTA, em vez +# de estourar a pĂĄgina. +module Pdf + class RomaneioPdf + include Pdf::FonteUtf8 + + MM = 72 / 25.4 + + MARGEM = 10 * MM + HEADER_H = 28 * MM # faixa do cabeçalho = margem superior do documento + FOOTER_H = 22 * MM # bloco DECLARO + assinatura + SPACER_H = 8 * MM # respiro entre a tabela e o DECLARO + + # O ReportLab pĂ”e 6pt de topPadding no Frame do SimpleDocTemplate por padrĂŁo, + # e o programa original nunca desconta isso. Medido contra o PDF de referĂȘncia: + # sem esses 6pt a tabela inteira sobe 9px (a 110 dpi) em relação ao documento + # que a GADE conhece. O Prawn nĂŁo tem esse padding, entĂŁo ele entra Ă  mĂŁo. + PADDING_TOPO = 6 + + # MĂĄximo de paradas por folha. Junto com as 2 linhas em branco e a linha de + # cabeçalho, Ă© o que faz a tabela ocupar exatamente uma pĂĄgina. + MAX_LINHAS = 33 + LINHAS_BRANCO = 2 + TOTAL_LINHAS = MAX_LINHAS + LINHAS_BRANCO + 1 + + AZUL = '1B3A6B' # cabeçalho da tabela e tĂ­tulos + ZEBRA = 'EAF0FB' # fundo das linhas alternadas + GRADE = 'AAAAAA' + + # nil = "o resto da largura" (a coluna ENDEREÇO absorve a sobra) + LARGURAS_MM = [8, 22, 50, nil, 22, 42].freeze + CABECALHOS = ['#', 'NOTA FISCAL', 'NOME', 'ENDEREÇO', 'APARELHO', 'TELEFONES'].freeze + CENTRALIZADAS = [0, 1, 4].freeze # #, NOTA FISCAL, APARELHO + + MIN_FONTE = 5 + + # Espaçamentos do rodapĂ©, CALIBRADOS contra o PDF de referĂȘncia (render a 110 dpi + # de Romaneiro_EMAD_SETEMBRO_2026.pdf, pĂĄgina 1). No original eles saem de trĂȘs + # tabelas do ReportLab com paddings prĂłprios (TOPPADDING 4, 17 e 2 + o padding + # padrĂŁo de 3 que ele nĂŁo sobrescreve); reproduzir as tabelas sĂł para chegar Ă s + # mesmas trĂȘs linhas de texto nĂŁo pagaria o custo — os nĂșmeros medidos, sim. + RODAPE_TOPO = 5.3 # da base da tabela (apĂłs o SPACER_H) atĂ© o DECLARO + RODAPE_ATE_ASSINATURA = 31.5 # do DECLARO atĂ© a linha de assinatura + RODAPE_ATE_ROTULO = 17.2 # da linha de assinatura atĂ© "ASSINATURA DO MOTORISTA" + RODAPE_ULTIMA_LINHA = 9 # altura da linha de "ASSINATURA DO MOTORISTA" (7,5pt) + + DECLARACAO = 'DECLARO QUE RECEBI OS PRODUTOS PARA REALIZAR AS ENTREGAS NOS ENDEREÇOS ACIMA.' + CAMPO_DATA = 'DATA: _______/_______/__________' + CAMPO_MOT = 'MOTORISTA: ______________________________' + + # `romaneio` sĂł precisa responder a #rotulo_plano e #linhas_vigentes — assim o + # arquivo carrega num container Ruby pelado (sem Rails) para a conferĂȘncia de + # layout em PNG, que Ă© o Ășnico jeito de validar PDF neste repositĂłrio. + # `veiculos: nil` = todos, na ordem do romaneio. + def initialize(romaneio:, veiculos: nil, logo: nil) + @romaneio = romaneio + # Ruby puro (sem ActiveSupport) daqui para baixo: esta classe precisa + # carregar num container sem Rails para a conferĂȘncia de layout em PNG. + lista = Array(veiculos).map(&:to_s).reject(&:empty?) + @veiculos = lista.empty? ? nil : lista + @logo = logo || self.class.logo_padrao + + @pdf = Prawn::Document.new(page_size: 'A4', page_layout: :landscape, + margin: [HEADER_H, MARGEM, MARGEM, MARGEM]) + registrar_fonte_utf8(@pdf) + + altura_util = @pdf.bounds.height - SPACER_H - FOOTER_H + @linha_h = altura_util / TOTAL_LINHAS + @pad = [1.0, (@linha_h - 9) / 2].max + @fonte_dados = [6.0, @linha_h - 2 * @pad - 2].max + @fonte_hdr = [8.0, @fonte_dados + 0.5].min + @larguras = calcular_larguras + + # Quantas linhas (dados + branco) cabem numa folha SEM empurrar a Ășltima + # linha do rodapĂ© para fora da ĂĄrea Ăștil. + # + # ⚠ NĂŁo Ă© decorativo: a conta do programa original (33 + 2 + 1 = 36 linhas) + # ignora tanto o topPadding de 6pt do Frame do ReportLab quanto os 15% a mais + # da linha de cabeçalho. Com 33 paradas de verdade o total estoura o orçamento + # em ~8pt e o "ASSINATURA DO MOTORISTA" some da folha — sem erro nenhum, o + # papel simplesmente sai sem o rĂłtulo da assinatura. Medido aqui com 34 + # paradas; nunca disparou em produção porque o maior veĂ­culo real tem 31. + altura_rodape = RODAPE_TOPO + RODAPE_ATE_ASSINATURA + RODAPE_ATE_ROTULO + + RODAPE_ULTIMA_LINHA + sobra = @pdf.bounds.height - SPACER_H - altura_rodape - PADDING_TOPO - @linha_h * 1.15 + @max_linhas_folha = (sobra / @linha_h).floor + end + + def self.logo_padrao + if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + Rails.root.join('public', 'logo-gade.png').to_s + else + File.expand_path('../../../public/logo-gade.png', __dir__) + end + end + + def render + paginas = montar_paginas + return @pdf.render if paginas.empty? + + paginas.each_with_index do |pagina, i| + @pdf.start_new_page unless i.zero? + desenhar_pagina(pagina) + end + + @pdf.render + end + + private + + # [{ veiculo:, folha:, total_folhas:, primeira_ordem:, linhas: [...] }, ...] + # + # VeĂ­culo com mais de MAX_LINHAS paradas vira N folhas. As alternativas eram + # descartar a parada #34 (pacote sem papel nenhum) ou deixar vazar para a pĂĄgina + # seguinte sob o cabeçalho do veĂ­culo ERRADO — corrupção silenciosa de um + # documento assinado. Cada folha leva o prĂłprio bloco de assinatura, e a + # numeração continua entre folhas para bater com a ordem de parada no celular + # do motorista. + def montar_paginas + agrupadas.flat_map do |veiculo, linhas| + fatias = linhas.each_slice(MAX_LINHAS).to_a + fatias.each_with_index.map do |fatia, i| + { veiculo: veiculo, folha: i + 1, total_folhas: fatias.size, + primeira_ordem: i * MAX_LINHAS + 1, linhas: fatia } + end + end + end + + def agrupadas + linhas = @romaneio.linhas_vigentes.to_a + linhas = linhas.select { |l| @veiculos.include?(l.veiculo.to_s) } if @veiculos + linhas.group_by { |l| l.veiculo.to_s } + end + + def desenhar_pagina(pagina) + desenhar_cabecalho(pagina) + @pdf.move_down PADDING_TOPO + desenhar_tabela(pagina) + @pdf.move_down SPACER_H + desenhar_rodape + end + + # ── Cabeçalho ──────────────────────────────────────────────────────────── + # Desenhado no `canvas` (coordenadas absolutas da pĂĄgina): a faixa vive DENTRO + # da margem superior, acima da ĂĄrea de conteĂșdo. + def desenhar_cabecalho(pagina) + titulo = "CONTROLE DE ENTREGA — #{pagina[:veiculo]}" + titulo += " (FOLHA #{pagina[:folha]}/#{pagina[:total_folhas]})" if pagina[:total_folhas] > 1 + base_y = @pdf.bounds.absolute_top + HEADER_H - MARGEM - 11 * MM + + @pdf.canvas do + if @logo && File.exist?(@logo.to_s) + @pdf.image @logo, at: [MARGEM, @pdf.bounds.absolute_top - MARGEM], + fit: [50 * MM, 17 * MM] + end + + # draw_text (e nĂŁo text_box) porque o `at` dele Ă© a BASELINE, igual ao + # drawCentredString do ReportLab — text_box ancoraria pelo topo da caixa e + # o tĂ­tulo subiria ~4pt em relação ao documento que a GADE conhece. + @pdf.fill_color AZUL + largura_titulo = @pdf.width_of(titulo, size: 15, style: :bold) + @pdf.draw_text titulo, size: 15, style: :bold, + at: [(@pdf.bounds.absolute_right - largura_titulo) / 2.0, base_y] + + desenhar_rotulo_plano(base_y) + @pdf.fill_color '000000' + end + end + + # "PLANO:" em negrito azul + o rĂłtulo em preto normal, ambos encostados na + # direita. O rĂłtulo Ă© medido primeiro para o label parar exatamente antes dele. + def desenhar_rotulo_plano(base_y) + rotulo = @romaneio.rotulo_plano.to_s.strip + return if rotulo.empty? + + direita = @pdf.bounds.absolute_right - MARGEM + largura = @pdf.width_of(rotulo, size: 9) + + @pdf.fill_color '000000' + @pdf.draw_text rotulo, at: [direita - largura, base_y], size: 9 + @pdf.fill_color AZUL + @pdf.draw_text 'PLANO: ', at: [direita - largura - @pdf.width_of('PLANO: ', size: 9, style: :bold), + base_y], size: 9, style: :bold + end + + # ── Tabela ─────────────────────────────────────────────────────────────── + def desenhar_tabela(pagina) + topo = @pdf.cursor + alt_h = @linha_h * 1.15 + y = topo + + desenhar_linha_cabecalho(y, alt_h) + y -= alt_h + + pagina[:linhas].each_with_index do |linha, i| + # No original o zebrado começa em EAF0FB: a primeira linha de DADOS Ă© a + # colorida. Inverter isso troca a paridade da tabela inteira. + cor = i.even? ? ZEBRA : 'FFFFFF' + desenhar_linha(y, cor, celulas(linha, pagina[:primeira_ordem] + i)) + y -= @linha_h + end + + # As linhas em branco existem para o operador anotar uma parada Ă  mĂŁo. Numa + # folha cheia nĂŁo hĂĄ espaço para elas — e insistir empurraria o rodapĂ© para + # fora do papel. EntĂŁo elas entram atĂ© onde couberem. + brancas = [LINHAS_BRANCO, @max_linhas_folha - pagina[:linhas].size].min + brancas = 0 if brancas.negative? + + brancas.times do |i| + cor = (pagina[:linhas].size + i).even? ? ZEBRA : 'FFFFFF' + desenhar_linha(y, cor, Array.new(6, '')) + y -= @linha_h + end + + desenhar_grade(topo, y, alt_h, pagina[:linhas].size + brancas) + @pdf.move_cursor_to y + end + + def celulas(linha, numero) + [numero.to_s, linha.nota_fiscal.to_s, linha.nome.to_s, + linha.endereco.to_s, linha.aparelho.to_s, linha.telefones.to_s] + end + + def desenhar_linha_cabecalho(y, altura) + @pdf.fill_color AZUL + @pdf.fill_rectangle [0, y], @pdf.bounds.width, altura + @pdf.fill_color 'FFFFFF' + escrever_celulas(y, altura, CABECALHOS, size: @fonte_hdr, style: :bold, tudo_centro: true) + @pdf.fill_color '000000' + end + + def desenhar_linha(y, cor, valores) + @pdf.fill_color cor + @pdf.fill_rectangle [0, y], @pdf.bounds.width, @linha_h + @pdf.fill_color '000000' + escrever_celulas(y, @linha_h, valores, size: @fonte_dados) + end + + def escrever_celulas(y, altura, valores, size:, style: :normal, tudo_centro: false) + x = 0 + valores.each_with_index do |valor, i| + largura = @larguras[i] + texto = valor.to_s + unless texto.empty? + alinhamento = tudo_centro || CENTRALIZADAS.include?(i) ? :center : :left + @pdf.text_box texto, at: [x + 3, y], width: largura - 6, height: altura, + size: size, style: style, valign: :center, + align: alinhamento, overflow: :shrink_to_fit, + min_font_size: MIN_FONTE, single_line: true + end + x += largura + end + end + + def desenhar_grade(topo, base, altura_cabecalho, total_linhas) + @pdf.line_width 0.4 + @pdf.stroke_color GRADE + + y = topo - altura_cabecalho + (total_linhas + 1).times do + @pdf.stroke_horizontal_line 0, @pdf.bounds.width, at: y + y -= @linha_h + end + + x = 0 + @larguras.each do |largura| + @pdf.stroke_vertical_line base, topo, at: x + x += largura + end + @pdf.stroke_vertical_line base, topo, at: @pdf.bounds.width + + @pdf.line_width 0.8 + @pdf.stroke_color '000000' + @pdf.stroke_rectangle [0, topo], @pdf.bounds.width, topo - base + @pdf.stroke_color '000000' + @pdf.line_width 1 + end + + # ── RodapĂ© ─────────────────────────────────────────────────────────────── + # O nome do motorista NÃO entra: a linha "MOTORISTA: ____" Ă© preenchida Ă  mĂŁo + # na retirada — Ă© assim que o documento Ă© assinado hoje. + def desenhar_rodape + col_l = @pdf.bounds.width * 0.62 + col_r = @pdf.bounds.width * 0.38 + + y = @pdf.cursor - RODAPE_TOPO + @pdf.text_box DECLARACAO, at: [0, y], width: col_l, size: 7.5, style: :bold, + overflow: :shrink_to_fit, single_line: true + @pdf.text_box CAMPO_DATA, at: [col_l, y], width: col_r, size: 8, style: :bold, + align: :right, single_line: true + + y -= RODAPE_ATE_ASSINATURA + @pdf.text_box '_' * 55, at: [0, y], width: col_l, size: 8, single_line: true + @pdf.text_box CAMPO_MOT, at: [col_l, y], width: col_r, size: 8, style: :bold, + align: :right, single_line: true + + y -= RODAPE_ATE_ROTULO + @pdf.text_box 'ASSINATURA DO MOTORISTA', at: [0, y], width: col_l, + size: 7.5, style: :bold, single_line: true + end + + # A coluna nil (ENDEREÇO) recebe o que sobrar da largura Ăștil. + def calcular_larguras + fixas = LARGURAS_MM.compact.sum * MM + resto = @pdf.bounds.width - fixas + LARGURAS_MM.map { |mm| mm.nil? ? resto : mm * MM } + end + end +end diff --git a/app/services/romaneios/enriquecimento_gade.rb b/app/services/romaneios/enriquecimento_gade.rb new file mode 100644 index 0000000..aefe627 --- /dev/null +++ b/app/services/romaneios/enriquecimento_gade.rb @@ -0,0 +1,101 @@ +# app/services/romaneios/enriquecimento_gade.rb +# +# Preenche APARELHO, TELEFONES e NOME do romaneio a partir da tabela da operação +# (gade_entregas_*). É o que dispensa a SEGUNDA PLANILHA do programa Python: lĂĄ o +# operador subia um .xlsx sĂł com NOTA FISCAL + STATUS para marcar quem Ă© NOVO; aqui +# o STATUS jĂĄ estĂĄ no banco, na prĂłpria operação. +# +# A tabela Ă© SOMENTE LEITURA (apenas SELECT), o nome passa por Operacao.valida? + +# quote_table_name e as NFs por conn.quote. +module Romaneios + class EnriquecimentoGade + # chave usada no SELECT => coluna real na gade. + # + # ⚠ `status` PRECISA sair aliasado: o espelho de rastreio tambĂ©m tem uma coluna + # `status` (o estado da visita), e qualquer query que junte as duas trocaria uma + # pela outra em silĂȘncio. Analytics::OperacaoMetricas jĂĄ faz esse mesmo alias + # (GADE_OPCIONAIS) pelo mesmo motivo. + COLUNAS = { + 'nota_fiscal' => 'nota_fiscal', + 'nome_completo' => 'nome_completo', + 'telefones' => 'telefones', + 'status' => 'status_gade' + }.freeze + + # Sem essas, nĂŁo hĂĄ o que enriquecer. + ESSENCIAIS = %w[nota_fiscal].freeze + + attr_reader :tabela + + def initialize(tabela) + @tabela = tabela.to_s + raise ArgumentError, "Operação invĂĄlida: #{@tabela}" unless Operacao.valida?(@tabela) + end + + # { '90507' => { aparelho: 'SIM', telefones: '(11) ...', nome: 'CICERA ...' } } + # NF que nĂŁo estiver na operação simplesmente nĂŁo vem no Hash — Ă© assim que a + # tela marca "fora da operação" (plano avulso / de inclusĂŁo), mesma semĂąntica de + # Analytics::NotasForaOperacao. + def para_notas(notas) + lista = Array(notas).map { |n| n.to_s.strip }.reject(&:empty?).uniq + return {} if lista.empty? + + registros(lista).each_with_object({}) do |r, memo| + nf = r['nota_fiscal'].to_s.strip + next if nf.empty? + + memo[nf] = { + aparelho: Normalizador.aparelho_por_status(r['status_gade']), + telefones: r['telefones'].to_s.strip, + nome: Normalizador.nome(r['nome_completo']) + } + end + end + + # Colunas que esta operação NÃO tem — a tela precisa DIZER isso. + # + # O esquema das gade_entregas_* varia por operação (sĂł `nota_fiscal` Ă© + # garantida). Quando falta `status`, o APARELHO sai vazio para TODO MUNDO — e um + # romaneio em que ninguĂ©m recebe aparelho, entregue calado, Ă© pior do que um + # aviso feio na tela. + def colunas_ausentes + @colunas_ausentes ||= COLUNAS.keys - existentes + end + + def utilizavel? + (ESSENCIAIS - existentes).empty? + end + + def label + Operacao.label(@tabela) + end + + private + + def conn + ActiveRecord::Base.connection + end + + def existentes + @existentes ||= conn.columns(@tabela).map(&:name) + end + + def registros(lista) + return [] unless utilizavel? + + valores = lista.map { |n| conn.quote(n) }.join(', ') + sql = "SELECT #{selects} FROM #{conn.quote_table_name(@tabela)} g " \ + "WHERE g.nota_fiscal IN (#{valores})" + conn.select_all(sql).to_a + end + + # Coluna que nĂŁo existir vira NULL com o mesmo alias, preservando a forma do + # resultado — mesmo padrĂŁo de SimpliRoute::PlanilhaCarga#selects. As chaves de + # COLUNAS sĂŁo whitelist fixa (nĂŁo hĂĄ entrada de usuĂĄrio no SQL). + def selects + COLUNAS.map do |coluna, apelido| + existentes.include?(coluna) ? "g.#{coluna} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}" + end.join(', ') + end + end +end diff --git a/app/services/romaneios/importador.rb b/app/services/romaneios/importador.rb new file mode 100644 index 0000000..7dac8d8 --- /dev/null +++ b/app/services/romaneios/importador.rb @@ -0,0 +1,215 @@ +# app/services/romaneios/importador.rb +# +# O ÚNICO lugar que sabe reconciliar um plano recebido com o romaneio jĂĄ editado. +# Os dois caminhos de entrada (API do SimpliRoute e upload do .xlsx) entregam a +# mesma forma de linha e caem aqui — senĂŁo a mesma parada importada por caminhos +# diferentes viraria duas. +# +# ── A REGRA (dirty tracking POR CAMPO, nĂŁo por linha) ─────────────────────── +# 1. Identidade = chave_origem (NF, ou digest do tĂ­tulo+endereço). VeĂ­culo e ordem +# NÃO entram: eles mudam quando o plano Ă© reotimizado e a parada continua sendo +# a mesma entrega. +# 2. Linha que jĂĄ existe: `origem` (o snapshot do plano) Ă© SEMPRE atualizada; os +# campos sĂŁo reescritos SÓ se o operador nĂŁo os tiver tocado. +# 3. Linha nova: insere. +# 4. Linha que sumiu do plano: sem edição humana, apaga; com edição, marca +# `removido_em` — apagar levaria junto o trabalho sem deixar rastro na tela. +# 5. NF repetida no plano: fica a primeira, as outras sĂŁo reportadas. +# +# Por que POR CAMPO e nĂŁo um "bloqueado" na linha inteira: a edição comum Ă© "o +# operador corrigiu um telefone". Travar a linha congelaria um endereço velho quando +# o SimpliRoute regeocodificasse, e o papel sairia com um endereço que o motorista +# nĂŁo acha. Por campo Ă© a Ășnica regra em que "corrigi o telefone" nĂŁo significa +# "tambĂ©m respondo pelo endereço". +module Romaneios + class Importador + Resultado = Struct.new(:novos, :atualizados, :preservados, :saidos, :duplicados, + :veiculos, :colunas_ausentes, keyword_init: true) do + def to_h + super.transform_values { |v| v.is_a?(Array) ? v : v.to_i } + end + + # Frase que a tela mostra depois de importar. NĂșmeros soltos nĂŁo dizem se + # deu certo; esta linha diz. + def resumo + partes = ["#{novos} nova(s)", "#{atualizados} atualizada(s)"] + partes << "#{preservados} mantida(s) por edição" if preservados.to_i.positive? + partes << "#{saidos} saĂ­ram do plano" if saidos.to_i.positive? + partes << "#{duplicados} NF repetida(s) no plano" if duplicados.to_i.positive? + partes.join(' · ') + end + end + + def initialize(romaneio:, linhas:, usuario: nil, origem: nil) + @romaneio = romaneio + @recebidas = Array(linhas) + @usuario = usuario + @origem = origem + end + + def aplicar! + novas, duplicados = deduplicar(@recebidas) + dados = enriquecer(novas) + + novos = atualizados = preservados = saidos = 0 + vistas = [] + + ActiveRecord::Base.transaction do + existentes = @romaneio.romaneio_linhas.index_by(&:chave_origem) + + dados.each do |linha| + vistas << linha[:chave_origem] + registro = existentes[linha[:chave_origem]] + + if registro.nil? + criar(linha) + novos += 1 + else + preservados += atualizar(registro, linha) + atualizados += 1 + end + end + + saidos = remover_ausentes(existentes, vistas) + sincronizar_veiculos(dados) + + @romaneio.update!( + origem: @origem || @romaneio.origem, + importado_em: Time.current, + importado_por: @usuario || @romaneio.importado_por + ) + end + + # Fora do bloco de propĂłsito: `return` dentro de uma transaction do Rails 7 + # COMMITA em silĂȘncio, o que Ă© uma pegadinha esperando um leitor distraĂ­do. + Resultado.new( + novos: novos, atualizados: atualizados, preservados: preservados, + saidos: saidos, duplicados: duplicados, + veiculos: dados.map { |l| l[:veiculo] }.uniq.size, + colunas_ausentes: @colunas_ausentes || [] + ) + end + + private + + # A primeira ocorrĂȘncia vence. Plano duplicado no SimpliRoute gera visita nova + # com a MESMA reference — repetir a parada no papel faria o motorista levar dois + # pacotes para o mesmo endereço. + def deduplicar(linhas) + vistas = {} + repetidas = 0 + + linhas.each do |linha| + chave = RomaneioLinha.chave_para(nota_fiscal: linha[:nota_fiscal], + titulo: linha[:titulo], endereco: linha[:endereco]) + if vistas.key?(chave) + repetidas += 1 + next + end + vistas[chave] = linha.merge(chave_origem: chave) + end + + [vistas.values, repetidas] + end + + # Aplica o que a operação sabe por cima do que o plano trouxe. + # PrecedĂȘncia (decidida com o usuĂĄrio): operação > plano. + def enriquecer(linhas) + tabela = @romaneio.operacao_tabela.to_s + return linhas if tabela.empty? + + gade = EnriquecimentoGade.new(tabela) + @colunas_ausentes = gade.colunas_ausentes + dados = gade.para_notas(linhas.map { |l| l[:nota_fiscal] }) + + linhas.map do |linha| + extra = dados[linha[:nota_fiscal].to_s] || {} + linha.merge( + aparelho: extra[:aparelho].to_s, + # A coluna `telefones` da operação Ă© a fonte completa; o "TEL:" das + # anotaçÔes do plano sĂł entra quando ela nĂŁo tem o nĂșmero. + telefones: extra[:telefones].to_s.empty? ? linha[:telefones].to_s : extra[:telefones], + nome: extra[:nome].to_s.empty? ? linha[:nome].to_s : extra[:nome] + ) + end + rescue ArgumentError + # Operação removida do banco entre a criação do romaneio e a reimportação: + # segue com o que o plano trouxe, e a tela avisa. + @colunas_ausentes = EnriquecimentoGade::COLUNAS.keys + linhas + end + + def campos(linha) + { 'nota_fiscal' => linha[:nota_fiscal].to_s, 'nome' => linha[:nome].to_s, + 'endereco' => linha[:endereco].to_s, 'aparelho' => linha[:aparelho].to_s, + 'telefones' => linha[:telefones].to_s } + end + + def criar(linha) + valores = campos(linha) + @romaneio.romaneio_linhas.create!( + valores.merge( + veiculo: linha[:veiculo], ordem: linha[:ordem], + chave_origem: linha[:chave_origem], origem: valores, campos_editados: [] + ) + ) + end + + # Devolve quantos campos foram PRESERVADOS por terem edição humana. + def atualizar(registro, linha) + valores = campos(linha) + preservados = 0 + + valores.each do |campo, valor| + if registro.editado?(campo) + preservados += 1 + next + end + registro[campo] = valor + end + + # VeĂ­culo e ordem sĂŁo a verdade do roteiro, nĂŁo conteĂșdo do documento — + # seguem o plano mesmo numa linha editada. + registro.veiculo = linha[:veiculo] + registro.ordem = linha[:ordem] + registro.origem = valores + registro.removido_em = nil + registro.save! + + preservados + end + + def remover_ausentes(existentes, vistas) + ausentes = existentes.except(*vistas).values + return 0 if ausentes.empty? + + limpas, editadas = ausentes.partition { |l| l.campos_editados.blank? } + RomaneioLinha.where(id: limpas.map(&:id)).delete_all if limpas.any? + RomaneioLinha.where(id: editadas.map(&:id)).update_all(removido_em: Time.current) if editadas.any? + + ausentes.size + end + + # Cria a folha de cada veĂ­culo novo e reordena as pĂĄginas. O `motorista` jĂĄ + # preenchido NÃO Ă© tocado: ele Ă© digitado na tela, nĂŁo vem do plano (o export + # real traz a coluna vazia em todas as linhas). + def sincronizar_veiculos(linhas) + ordem = linhas.map { |l| l[:veiculo] }.uniq.sort + existentes = @romaneio.romaneio_veiculos.index_by(&:veiculo) + + ordem.each_with_index do |veiculo, i| + registro = existentes[veiculo] || + @romaneio.romaneio_veiculos.build(veiculo: veiculo) + registro.posicao = i + motorista = linhas.find { |l| l[:veiculo] == veiculo && l[:motorista].to_s != '' } + registro.motorista = motorista[:motorista] if motorista && registro.motorista.to_s.empty? + registro.save! + end + + # VeĂ­culo que saiu do plano e ficou sem nenhuma parada nĂŁo tem folha para + # imprimir. + sobrando = existentes.keys - ordem + @romaneio.romaneio_veiculos.where(veiculo: sobrando).destroy_all if sobrando.any? + end + end +end diff --git a/app/services/romaneios/normalizador.rb b/app/services/romaneios/normalizador.rb new file mode 100644 index 0000000..a331063 --- /dev/null +++ b/app/services/romaneios/normalizador.rb @@ -0,0 +1,87 @@ +# app/services/romaneios/normalizador.rb +# +# Parsing compartilhado entre as duas fontes de plano (API e .xlsx). Existe para os +# dois caminhos entregarem EXATAMENTE a mesma forma de linha ao Romaneios::Importador +# — se cada um normalizasse do seu jeito, o mesmo plano importado por caminhos +# diferentes geraria linhas com chave_origem diferente e a reconciliação duplicaria +# tudo na primeira reimportação. +# +# Namespace no plural (Romaneios::) para nĂŁo colidir com a constante do model +# Romaneio. +module Romaneios + module Normalizador + # "NF 90507 - CICERA BESERRA DOS ANJOS" -> ['90507', 'CICERA BESERRA DOS ANJOS'] + NF_TITULO = /\ANF\s+(\d+)\s*-\s*(.+)\z/i + + # "TEL: (11) 99262-0594 / (11) 99824-8099" — o plano guarda o telefone dentro + # das AnotaçÔes, em texto livre. Pegamos TODOS os que casarem: uma parada com + # dois nĂșmeros precisa dos dois no papel (o motorista liga do portĂŁo). + TEL_ANOTACAO = /\(?\d{2}\)?\s*\d{4,5}-?\d{4}/ + + module_function + + # Devolve [nf, nome]. TĂ­tulo fora do padrĂŁo vira [nil, tĂ­tulo] — nĂŁo Ă© erro: + # plano avulso e de inclusĂŁo Ă s vezes chegam sem o prefixo "NF n - ". + def nf_e_nome(titulo) + texto = titulo.to_s.strip + m = NF_TITULO.match(texto) + return [m[1], m[2].strip] if m + + [nil, texto] + end + + # O SimpliRoute devolve o endereço com sufixo ", Brazil"; o documento impresso + # nunca teve isso. Caixa alta porque Ă© como o romaneio sai desde sempre. + def endereco(valor) + valor.to_s + .gsub(/,\s*Brazil\s*\z/i, '') + .strip + .gsub(/\s+/, ' ') + .upcase + end + + # Telefones a partir das anotaçÔes do plano. SĂł Ă© usado quando a operação nĂŁo + # tem o nĂșmero — a coluna `telefones` da gade_entregas_* Ă© a fonte preferida + # (ver Romaneios::EnriquecimentoGade). + def telefones_de_anotacao(anotacoes) + texto = anotacoes.to_s + return '' unless texto.match?(/TEL/i) + + texto.scan(TEL_ANOTACAO).map(&:strip).uniq.join(' / ') + end + + def nome(valor) + valor.to_s.strip.gsub(/\s+/, ' ').upcase + end + + # 'SIM' quando o paciente Ă© NOVO na operação; vazio caso contrĂĄrio. + # É o que a segunda planilha do programa Python fazia Ă  mĂŁo. + def aparelho_por_status(status_gade) + status_gade.to_s.strip.casecmp('NOVO').zero? ? 'SIM' : '' + end + + # Uma linha crua do plano (de qualquer fonte) vira a forma canĂŽnica que o + # Importador consome. + def linha(veiculo:, ordem:, titulo:, endereco:, anotacoes: nil, + nota_fiscal: nil, tracking_id: nil, motorista: nil) + # `endereco` aqui Ă© a variĂĄvel local do kwarg — o mĂ©todo homĂŽnimo continua + # acessĂ­vel com parĂȘnteses, mas renomeamos para ninguĂ©m tropeçar. + endereco_cru = endereco + nf_titulo, nome_titulo = nf_e_nome(titulo) + nf = nota_fiscal.to_s.strip + nf = nf_titulo.to_s if nf.empty? + + { + veiculo: veiculo.to_s.strip, + ordem: ordem.to_i, + motorista: motorista.to_s.strip, + nota_fiscal: nf.to_s, + nome: nome(nome_titulo), + endereco: endereco(endereco_cru), + telefones: telefones_de_anotacao(anotacoes), + tracking_id: tracking_id.to_s, + titulo: titulo.to_s + } + end + end +end diff --git a/app/services/romaneios/plano_do_dia.rb b/app/services/romaneios/plano_do_dia.rb new file mode 100644 index 0000000..4189bf9 --- /dev/null +++ b/app/services/romaneios/plano_do_dia.rb @@ -0,0 +1,153 @@ +# app/services/romaneios/plano_do_dia.rb +# +# Busca o plano de UMA data e devolve as paradas na forma canĂŽnica do +# Romaneios::Normalizador — a mesma que Romaneios::PlanoPlanilha entrega. +# +# ⚠ NÃO estĂĄ verificado se a visita de /v1/routes/visits/ carrega veĂ­culo/ordem, e +# isso nĂŁo Ă© testĂĄvel na mĂĄquina de desenvolvimento (sem token, sem .env). Por isso +# aqui existe uma ESCADA DE QUEDA explĂ­cita, e nĂŁo um caminho feliz Ășnico: +# +# a) rotas da API trazem vehicle + lista ordenada de visitas -> origem :api +# b) as visitas trazem alguma chave de veĂ­culo/ordem -> origem :api +# c) nada disso (ou sem token) -> ESPELHO local +# d) espelho vazio na data -> NotFound, com a +# mensagem que manda o operador usar o .xlsx do plano +# +# A rung (c) nĂŁo Ă© consolo: o romaneio Ă© impresso ANTES do dia começar, e ela cobre +# "plano publicado e jĂĄ sincronizado, mas a API fora do ar" — a falha mais provĂĄvel. +# Rode bin/sondar_plano_do_dia no servidor para fechar (a)/(b). +module Romaneios + class PlanoDoDia + # Chaves candidatas nos hashes da API. SĂł as usamos se vierem PREENCHIDAS — + # a API devolve chave existente com valor nil o tempo todo. + CHAVES_VEICULO = %w[vehicle vehicle_name vehicle_id].freeze + CHAVES_MOTORISTA = %w[driver driver_name].freeze + CHAVES_ORDEM = %w[order sequence position route_order].freeze + + attr_reader :origem + + def initialize(data:, client: nil) + @data = data.to_date + @client = client + @origem = nil + end + + def linhas + @linhas ||= carregar + end + + private + + def carregar + linhas = da_api + return linhas if linhas.any? + + linhas = do_espelho + return linhas if linhas.any? + + raise SimpliRoute::NotFound, + "O plano de #{@data.strftime('%d/%m/%Y')} nĂŁo estĂĄ disponĂ­vel no SimpliRoute " \ + 'nem no espelho de rastreio. Envie a planilha do plano (.xlsx).' + end + + # ── (a) e (b) ──────────────────────────────────────────────────────────── + def da_api + return [] unless SimpliRoute.configurado? + + cliente = @client || SimpliRoute::Client.new + visitas = cliente.visitas_da_data(@data) + return [] if visitas.empty? + + linhas = por_rotas(cliente, visitas) + linhas = por_visitas(visitas) if linhas.empty? + @origem = 'api' if linhas.any? + linhas + rescue SimpliRoute::Error + # Token ausente/expirado ou API fora: cai para o espelho, que Ă© a mesma + # informação jĂĄ sincronizada. + [] + end + + # (a) A rota traz o veĂ­culo e a lista de visitas na ordem do roteiro. + def por_rotas(cliente, visitas) + rotas = cliente.rotas_da_data(@data) + return [] if rotas.empty? + + por_id = visitas.index_by { |v| v['id'] } + linhas = [] + + rotas.each do |rota| + veiculo = primeiro_valor(rota, CHAVES_VEICULO) + next if veiculo.empty? + + ids = Array(rota['visits']).map { |v| v.is_a?(Hash) ? v['id'] : v } + ids = visitas.select { |v| v['route'].to_s == rota['id'].to_s }.map { |v| v['id'] } if ids.empty? + + ids.each_with_index do |id, i| + visita = por_id[id] + next if visita.nil? + + linhas << monta(visita, veiculo, primeiro_valor(rota, CHAVES_MOTORISTA), i + 1) + end + end + + linhas + end + + # (b) A visita traz o veĂ­culo direto; a ordem sai de `order`/`sequence` ou, + # na falta, do horĂĄrio estimado de chegada. + def por_visitas(visitas) + com_veiculo = visitas.reject { |v| primeiro_valor(v, CHAVES_VEICULO).empty? } + return [] if com_veiculo.empty? + + com_veiculo.group_by { |v| primeiro_valor(v, CHAVES_VEICULO) }.flat_map do |veiculo, lista| + ordenadas(lista).each_with_index.map do |visita, i| + monta(visita, veiculo, primeiro_valor(visita, CHAVES_MOTORISTA), i + 1) + end + end + end + + def ordenadas(lista) + lista.sort_by.with_index do |v, i| + chave = CHAVES_ORDEM.filter_map { |k| v[k] }.first + [chave.nil? ? Float::INFINITY : chave.to_i, v['estimated_time_arrival'].to_s, i] + end + end + + def monta(visita, veiculo, motorista, ordem) + Normalizador.linha( + veiculo: veiculo, ordem: ordem, motorista: motorista, + titulo: visita['title'], endereco: visita['address'], + anotacoes: visita['notes'], nota_fiscal: visita['reference'], + tracking_id: visita['tracking_id'] + ) + end + + def primeiro_valor(hash, chaves) + chaves.filter_map { |k| hash[k] }.map(&:to_s).find { |v| !v.strip.empty? }.to_s.strip + end + + # ── (c) Espelho local ──────────────────────────────────────────────────── + # `db_reem_simplerout_2026` jĂĄ tem driver, vehicle, title, address, notes e eta. + # SĂł leitura, como sempre. + def do_espelho + registros = Entrega.da_conta_gade + .no_periodo(@data, @data) + .where.not(vehicle: [nil, '']) + .order(:vehicle, :eta) + .to_a + return [] if registros.empty? + + @origem = 'espelho' + registros.group_by(&:vehicle).flat_map do |veiculo, lista| + lista.each_with_index.map do |e, i| + Normalizador.linha( + veiculo: veiculo, ordem: i + 1, motorista: e.driver, + titulo: e.title, endereco: e.address, anotacoes: e.notes, + nota_fiscal: e.reference_id, tracking_id: e.tracking_id + ) + end + end + end + end +end diff --git a/app/services/romaneios/plano_planilha.rb b/app/services/romaneios/plano_planilha.rb new file mode 100644 index 0000000..fef63f4 --- /dev/null +++ b/app/services/romaneios/plano_planilha.rb @@ -0,0 +1,140 @@ +# app/services/romaneios/plano_planilha.rb +# +# LĂȘ o .xlsx do PLANO exportado do SimpliRoute e devolve as linhas na mesma forma +# que SimpliRoute::PlanoDoDia — Ă© o caminho B do romaneio, para quando a API nĂŁo +# responde ou o plano do dia ainda nĂŁo foi publicado. +# +# ⚠ O arquivo NÃO Ă© persistido em lugar nenhum. Ele tem nome, endereço e telefone +# de paciente; o ActiveStorage aqui Ă© configurado condicionalmente e nĂŁo hĂĄ polĂ­tica +# de retenção para pendurar isso. Lemos do tempfile e descartamos. +require 'roo' + +module Romaneios + class PlanoPlanilha + class Erro < StandardError; end + + # Cabeçalhos aceitos, jĂĄ normalizados (sem acento, minĂșsculo, espaço colapsado). + # + # ⚠ Casamos por NOME e nunca por posição: o export do cliente varia. Medido no + # arquivo real (SimpliRoute_Plan_ 2026-08-31.xlsx, 2047 linhas / 72 veĂ­culos): + # a coluna "Motorista" vem VAZIA em todas as linhas e NÃO existem as colunas + # "Aparelho", "Pessoa de contato" nem "Telefone de contato" — que o programa + # Python original lia. Faltar qualquer uma delas Ă© o caso NORMAL, nĂŁo erro. + # + # "id de referencua" Ă© um typo do prĂłprio SimpliRoute; aceitamos os dois porque + # eles podem corrigir a qualquer momento e aĂ­ o romaneio pararia de achar a NF. + COLUNAS = { + veiculo: ['veiculo', 'vehicle'], + motorista: ['motorista', 'driver'], + indice: ['indice de rota', 'route index'], + ordem: ['ordem de entrega', 'delivery order'], + titulo: ['titulo', 'title'], + endereco: ['endereco', 'address'], + anotacoes: ['anotacoes', 'notes'], + nota_fiscal: ['id de referencua', 'id de referencia', 'reference id', 'nota fiscal'] + }.freeze + + TAMANHO_MAXIMO = 20 * 1024 * 1024 + LINHAS_MAXIMO = 20_000 + ASSINATURA_ZIP = "PK\x03\x04".b.freeze + + # `entrada` Ă© o tempfile do upload (ou um caminho, nos specs). + def initialize(entrada) + @caminho = caminho_de(entrada) + validar_arquivo! + end + + def linhas + @linhas ||= montar + end + + private + + def caminho_de(entrada) + return entrada.tempfile.path if entrada.respond_to?(:tempfile) + return entrada.path if entrada.respond_to?(:path) + + entrada.to_s + end + + # Guardas antes de entregar o arquivo para a roo: um .xlsx Ă© um zip, e um zip + # inesperado (ou gigante) vira consumo de memĂłria sem limite dentro do processo + # que atende a requisição. + def validar_arquivo! + raise Erro, 'Envie a planilha do plano (.xlsx).' unless File.exist?(@caminho) + raise Erro, 'A planilha passa de 20 MB — nĂŁo parece ser o plano do dia.' if File.size(@caminho) > TAMANHO_MAXIMO + + assinatura = File.binread(@caminho, 4) + return if assinatura == ASSINATURA_ZIP + + raise Erro, 'O arquivo nĂŁo Ă© um .xlsx. Baixe o plano do SimpliRoute em Excel e envie de novo.' + end + + def montar + planilha = Roo::Excelx.new(@caminho) + cabecalho = mapear_colunas(planilha.row(1)) + + if cabecalho[:veiculo].nil? + raise Erro, 'A planilha nĂŁo tem a coluna "VeĂ­culo" — confira se Ă© o plano ' \ + 'do SimpliRoute e nĂŁo a planilha de carga.' + end + + total = planilha.last_row.to_i + raise Erro, "A planilha tem #{total} linhas — acima do limite de #{LINHAS_MAXIMO}." if total > LINHAS_MAXIMO + + registros = (2..total).filter_map { |i| montar_linha(planilha.row(i), cabecalho, i) } + ordenar(registros) + ensure + planilha&.close if planilha.respond_to?(:close) + end + + # { chave => Ă­ndice da coluna }. Coluna ausente simplesmente nĂŁo entra no hash. + def mapear_colunas(cabecalho) + normalizados = Array(cabecalho).map { |c| normalizar_cabecalho(c) } + + COLUNAS.each_with_object({}) do |(chave, aceitos), memo| + idx = normalizados.index { |c| aceitos.include?(c) } + memo[chave] = idx if idx + end + end + + # Sem acento, minĂșsculo, espaço colapsado — o export alterna maiĂșscula/minĂșscula + # e o acento varia com a versĂŁo do arquivo. + def normalizar_cabecalho(valor) + I18n.transliterate(valor.to_s).downcase.strip.gsub(/\s+/, ' ') + end + + def montar_linha(linha, cabecalho, numero_da_linha) + valor = ->(chave) { cabecalho[chave] ? linha[cabecalho[chave]] : nil } + + veiculo = valor.call(:veiculo).to_s.strip + titulo = valor.call(:titulo).to_s.strip + ender = valor.call(:endereco).to_s.strip + # Linha sem veĂ­culo, ou sem nada que identifique a parada, Ă© lixo de rodapĂ© + # do prĂłprio export — pular em silĂȘncio Ă© o certo. + return nil if veiculo.empty? || (titulo.empty? && ender.empty?) + + Normalizador.linha( + veiculo: veiculo, + # A ordem definitiva Ă© atribuĂ­da em #ordenar; aqui guardamos o que o + # arquivo trouxer para poder ordenar depois. + ordem: valor.call(:ordem) || valor.call(:indice) || numero_da_linha, + titulo: titulo, + endereco: ender, + anotacoes: valor.call(:anotacoes), + nota_fiscal: valor.call(:nota_fiscal), + motorista: valor.call(:motorista) + ) + end + + # Ordem final: por veĂ­culo, pela ordem de entrega do plano. Renumeramos de 1 a N + # dentro de cada veĂ­culo porque Ă© isso que vira a coluna "#" do papel — e o + # "Índice de rota" do export nĂŁo Ă© 1..N por veĂ­culo. + def ordenar(registros) + registros.group_by { |r| r[:veiculo] }.flat_map do |_veiculo, linhas| + linhas.sort_by.with_index { |r, i| [r[:ordem].to_i, i] } + .each_with_index.map { |r, i| r.merge(ordem: i + 1) } + end + end + end +end diff --git a/app/services/simpli_route/client.rb b/app/services/simpli_route/client.rb index 5f59e78..e769f06 100644 --- a/app/services/simpli_route/client.rb +++ b/app/services/simpli_route/client.rb @@ -64,6 +64,22 @@ module SimpliRoute Array(get(caminho)) end + # Rotas PLANEJADAS da data (Array de hashes). É aqui que costuma morar o par + # veĂ­culo/motorista, que a visita talvez nĂŁo carregue — e o romaneio precisa do + # veĂ­culo para saber quantas folhas imprimir. + # + # ⚠ NÃO VERIFICADO contra a API real: nĂŁo hĂĄ token na mĂĄquina de + # desenvolvimento. Por isso Ă© best-effort (endpoint inexistente devolve [], nĂŁo + # explode) e por isso Romaneios::PlanoDoDia tem uma escada de queda atĂ© o + # espelho local. Rode `bin/sondar_plano_do_dia` no servidor antes de confiar + # nesta chamada — e anote o resultado no cabeçalho daquele script, como foi + # feito em bin/sondar_busca_nf. + def rotas_da_data(data) + Array(get("/v1/routes/routes/?planned_date=#{data.to_date.iso8601}")) + rescue Error + [] + end + # 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) + # planned_date. Casa pelo tracking_id; se nĂŁo achar, tenta pela NF. diff --git a/app/views/admin/romaneios/_avisos.html.erb b/app/views/admin/romaneios/_avisos.html.erb new file mode 100644 index 0000000..9f10cdc --- /dev/null +++ b/app/views/admin/romaneios/_avisos.html.erb @@ -0,0 +1,48 @@ +<%# DivergĂȘncias explicadas ONDE elas aparecem — nĂŁo em documentação. %> +<% multifolha = @romaneio.veiculos_multifolha %> + +<% if @colunas_ausentes.include?('status') && @romaneio.operacao_tabela.present? %> +
+ ⚠ +

+ A operação <%= @romaneio.operacao_label %> nĂŁo tem a coluna + STATUS, entĂŁo nĂŁo dĂĄ para saber quem Ă© paciente novo — a coluna + APARELHO saiu vazia para todos e precisa ser preenchida Ă  mĂŁo. + Um romaneio em que ninguĂ©m recebe aparelho, entregue calado, vira aparelho nĂŁo entregue. +

+
+<% end %> + +<% if @colunas_ausentes.include?('telefones') && @romaneio.operacao_tabela.present? %> +
+ ☎ +

+ A operação <%= @romaneio.operacao_label %> nĂŁo tem a coluna + TELEFONES. O que aparece na tabela veio do “TEL:” das anotaçÔes do + plano, que nem sempre traz todos os nĂșmeros. +

+
+<% end %> + +<% if @romaneio.operacao_tabela.blank? %> +
+ â„č +

+ Este romaneio foi importado sem operação vinculada: a coluna APARELHO + não é preenchida sozinha e o telefone vem só das anotaçÔes do plano. +

+
+<% end %> + +<% if multifolha.any? %> +
+ đŸ–šïž +

+ <% multifolha.each do |veiculo, total| %> + <%= veiculo %> tem <%= total %> paradas → + sai em <%= @romaneio.folhas_de(veiculo) %> folhas<%= ',' unless veiculo == multifolha.keys.last %> + <% end %> + — cada folha vai com o próprio bloco de assinatura, e a numeração continua entre elas. +

+
+<% end %> diff --git a/app/views/admin/romaneios/_celula.html.erb b/app/views/admin/romaneios/_celula.html.erb new file mode 100644 index 0000000..ad83ff8 --- /dev/null +++ b/app/views/admin/romaneios/_celula.html.erb @@ -0,0 +1,13 @@ +<%# Uma célula editåvel + o marcador de "editado". %> +
+ + <%= render 'marcador', linha: linha, campo: campo %> +
diff --git a/app/views/admin/romaneios/_controles.html.erb b/app/views/admin/romaneios/_controles.html.erb new file mode 100644 index 0000000..1186724 --- /dev/null +++ b/app/views/admin/romaneios/_controles.html.erb @@ -0,0 +1,55 @@ +<%# Seletor de veículo + os dois campos de cabeçalho da folha. %> +
+
+ +
+ <%# â€č â€ș ao lado do select: com 72 veĂ­culos, uma lista lateral vira armadilha + de scroll, mas percorrer o plano carro a carro Ă© o que o operador faz. %> + + + +
+
+ +
+
+ + +
+
+ + <% atual = @veiculos.find { |v| v.veiculo == @veiculo } %> + +
+
+ +
+ + +
+
diff --git a/app/views/admin/romaneios/_marcador.html.erb b/app/views/admin/romaneios/_marcador.html.erb new file mode 100644 index 0000000..c737be7 --- /dev/null +++ b/app/views/admin/romaneios/_marcador.html.erb @@ -0,0 +1,12 @@ +<%# Ponto laranja = campo tocado por humano, e por isso protegido na reimportação. + O botĂŁo â†ș devolve o valor do plano (e destrava o campo de novo) — sem ele, uma + tecla acidental seria permanente. %> + + <% if linha.editado?(campo) %> + + <% end %> + diff --git a/app/views/admin/romaneios/_removidas.html.erb b/app/views/admin/romaneios/_removidas.html.erb new file mode 100644 index 0000000..c1234b9 --- /dev/null +++ b/app/views/admin/romaneios/_removidas.html.erb @@ -0,0 +1,21 @@ +<%# Paradas que saĂ­ram do plano mas tinham edição humana. NĂŁo sĂŁo impressas, e nĂŁo + sĂŁo apagadas: quem decide o que fazer com elas Ă© o operador. %> +<% if linhas.any? %> +
+

+ <%= linhas.size %> parada(s) saĂ­ram do plano +

+

+ Elas tinham edição sua, então foram guardadas em vez de apagadas — e não entram no PDF. + Se voltarem ao plano numa próxima importação, voltam sozinhas para a folha. +

+
    + <% linhas.each do |linha| %> +
  • + <%= linha.veiculo %> · + NF <%= linha.nota_fiscal.presence || '—' %> · <%= linha.nome %> +
  • + <% end %> +
+
+<% end %> diff --git a/app/views/admin/romaneios/_tabela.html.erb b/app/views/admin/romaneios/_tabela.html.erb new file mode 100644 index 0000000..815470d --- /dev/null +++ b/app/views/admin/romaneios/_tabela.html.erb @@ -0,0 +1,59 @@ +<%# Tabela editĂĄvel do veĂ­culo selecionado. + SEM paginação de propĂłsito: acima de 1366px a lista inteira Ă© o certo — esconder + item em monitor grande atrapalha atĂ© o Ctrl+F do navegador, e conferir romaneio Ă© + exatamente procurar uma NF na lista. A busca acima filtra sem esconder nada. + + por cĂ©lula, e nĂŁo contenteditable: colar do Excel num contenteditable + traz HTML junto. %> +
+
+

<%= linhas.size %> paradas

+ AlteraçÔes salvam sozinhas +
+ +
+ + + + + + + + + + + + + <% linhas.each_with_index do |linha, i| %> + + + <% %w[nota_fiscal nome endereco].each do |campo| %> + + <% end %> + + <%# APARELHO sĂł assume 'SIM' ou vazio: um botĂŁo de dois estados Ă© alvo de + toque de verdade e nĂŁo deixa digitar um terceiro valor. %> + + + + + <% end %> + +
#Nota fiscalNomeEndereçoAparelhoTelefones
<%= i + 1 %> + <%= render 'celula', linha: linha, campo: campo %> + + + <%= render 'marcador', linha: linha, campo: 'aparelho' %> + + <%= render 'celula', linha: linha, campo: 'telefones' %> +
+
+
diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb new file mode 100644 index 0000000..5f1b918 --- /dev/null +++ b/app/views/admin/romaneios/index.html.erb @@ -0,0 +1,144 @@ +<%# app/views/admin/romaneios/index.html.erb %> +
+ +
+

Romaneio de entrega

+

+ Monta o "CONTROLE DE ENTREGA" — uma folha por veículo, assinada pelo motorista na retirada +

+
+ + <%= render 'shared/flash' %> + +
+ â„č +

+ A coluna APARELHO Ă© marcada sozinha para quem estĂĄ como + NOVO na operação do mĂȘs, e o telefone vem da prĂłpria + operação — nĂŁo Ă© preciso subir uma segunda planilha. Tudo continua editĂĄvel na tela + antes de imprimir. +

+
+ + <%# Importação %> +
+

Importar plano

+ + <%= form_with url: admin_romaneios_path, method: :post, multipart: true, + data: { turbo: false }, class: 'space-y-4' do %> +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + <%# Reserva: o plano do dia pode ainda nĂŁo estar publicado. %> +
+ + Ou enviar a planilha do plano (.xlsx) + +
+ + +
+

+ Use quando o plano do dia ainda nĂŁo estiver publicado no SimpliRoute. + O arquivo Ă© lido na hora e nĂŁo fica guardado no sistema. +

+
+
+ <% end %> +
+ + <%# Romaneios jĂĄ importados %> +
+ <% if @romaneios.empty? %> +
+

Nenhum romaneio ainda.

+

Escolha a data do plano acima e clique em “Buscar no SimpliRoute”.

+
+ <% else %> + + + + + + + + + + + + + <% @romaneios.each do |r| %> + + + + + + + + + <% end %> + +
DataPlanoOperaçãoVeículosOrigem
<%= l r.planned_date, format: '%d/%m/%Y' %><%= r.rotulo_plano.presence || '—' %><%= r.operacao_label || '—' %><%= r.romaneio_veiculos.size %> + <%= r.origem %> + + <%= link_to rotulo(:editar, 'Abrir'), admin_romaneio_path(r), + class: 'text-orange-400 hover:text-orange-300 font-medium' %> + <%# button_to em BLOCO: sem bloco ele vira , que + nĂŁo aceita o SVG do Ă­cone dentro (ver ApplicationHelper#rotulo). %> + <%= button_to admin_romaneio_path(r), method: :delete, + form: { data: { turbo_confirm: 'Excluir este romaneio e todas as ediçÔes feitas nele?' }, + class: 'inline-block ml-3' }, + class: 'text-gray-500 hover:text-red-400' do %> + <%= icone :excluir, cor: nil %> Excluir + <% end %> +
+ <% end %> +
+
diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb new file mode 100644 index 0000000..5ad792e --- /dev/null +++ b/app/views/admin/romaneios/show.html.erb @@ -0,0 +1,89 @@ +<%# app/views/admin/romaneios/show.html.erb + Editor do romaneio: tabela editåvel à esquerda, o PDF REAL à direita. + + A prévia é um +
+
+
+ <% end %> +
diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 31f641b..941839a 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -56,7 +56,8 @@ <%# Administração — a seção inteira some quando nenhum item dela está liberado. %> <% itens_admin = %w[admin.usuarios admin.perfis admin.configuracoes admin.auditoria - admin.planilha_simpli_route admin.edicao_lancamento] %> + admin.planilha_simpli_route admin.edicao_lancamento + admin.romaneio] %> <% if itens_admin.any? { |chave| current_user.pode?(chave) } %>

Administração

@@ -75,6 +76,9 @@ <% if current_user.pode?('admin.planilha_simpli_route') %> <%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %> <% end %> + <% if current_user.pode?('admin.romaneio') %> + <%= nav_link_to 'Romaneio de entrega', admin_romaneios_path, icon: :imprimir %> + <% end %> <% if current_user.pode?('admin.edicao_lancamento') %> <%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %> <% end %> diff --git a/bin/docker-boot b/bin/docker-boot new file mode 100755 index 0000000..ebd8348 --- /dev/null +++ b/bin/docker-boot @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Boot do container do Rails (deploy e todo restart do Docker). +# +# POR QUE ISSO VIROU UM SCRIPT: antes era um `command:` de uma linha sĂł no +# docker-compose.yml. Com `restart: unless-stopped` o boot passou a rodar +# sozinho — depois de queda, de reboot do servidor e de cada deploy — e um +# passo que falha em silĂȘncio vira loop de reinĂ­cio sem ninguĂ©m entender por +# quĂȘ. Aqui cada etapa diz o que estĂĄ fazendo no log do container +# (`docker compose logs -f app`). +# +# ORDEM DOS PASSOS (nĂŁo troque sem ler o motivo): +# 1. relĂłgio — sĂł imprime, para diagnĂłstico (ver comentĂĄrio abaixo). +# 2. pid ĂłrfĂŁo — se o container morreu sem limpar, o Puma se recusa a subir. +# 3. gems — o volume bundle_cache SOMBREIA os gems da imagem, entĂŁo +# gem nova no Gemfile nĂŁo chega no container sĂł com --build. +# 4. migrations — db:prepare: cria o banco se nĂŁo existir, roda as migrations +# pendentes e sĂł faz seed em banco recĂ©m-criado (idempotente). +# 5. cron — grava o crontab do config/schedule.rb e sobe o daemon. +# 6. Puma — processo em primeiro plano (Ă© ele que segura o container). +set -uo pipefail + +log() { echo "[boot] $*"; } + +# ── 1. RelĂłgio ─────────────────────────────────────────────────────────────── +# O container NÃO tem relĂłgio prĂłprio: ele lĂȘ o clock do kernel do HOST. NĂŁo +# adianta rodar NTP aqui dentro (exigiria CAP_SYS_TIME e mexeria na hora do +# servidor inteiro) — quem sincroniza Ă© o host, via chrony/NTS; ver +# deploy/ntp-seguro.sh. O que Ă© do container Ă© sĂł o FUSO (TZ), fixado no +# Dockerfile. Se a linha abaixo aparecer em UTC, o rebuild da imagem nĂŁo pegou. +log "relĂłgio do container: $(date '+%Y-%m-%d %H:%M:%S %Z (%z)') | UTC: $(date -u '+%H:%M:%S')" + +# ── 2. PID ĂłrfĂŁo do Puma ───────────────────────────────────────────────────── +rm -f tmp/pids/server.pid + +# ── 3. Gems ────────────────────────────────────────────────────────────────── +if ! bundle check >/dev/null 2>&1; then + log "gem faltando no volume bundle_cache — rodando bundle install" + bundle install || { log "ERRO: bundle install falhou"; exit 1; } +fi + +# ── 4. Migrations ──────────────────────────────────────────────────────────── +# O PostgreSQL Ă© EXTERNO ao compose: num reboot do servidor o Rails pode subir +# antes do banco aceitar conexĂŁo. Sem a espera abaixo o container sairia com +# erro e o `restart: unless-stopped` ficaria reiniciando em loop — parecendo +# problema de migration quando Ă© sĂł ordem de boot. 10 tentativas × 6s = 1 min. +tentativas=10 +for i in $(seq 1 $tentativas); do + log "migrations (db:prepare) — tentativa $i/$tentativas" + if bundle exec rails db:prepare; then + log "banco pronto e migrations aplicadas" + break + fi + if [ "$i" -eq "$tentativas" ]; then + # Aqui Ă© falha DE VERDADE (migration quebrada ou credencial errada): sair + # com erro Ă© melhor do que subir a aplicação contra um schema desatualizado. + log "ERRO: db:prepare falhou apĂłs $tentativas tentativas — veja o erro acima" + exit 1 + fi + log "banco indisponĂ­vel ou migration falhou; nova tentativa em 6s" + sleep 6 +done + +# ── 5. Cron (best-effort) ──────────────────────────────────────────────────── +# Best-effort de propĂłsito: agendamento quebrado atrasa um relatĂłrio, mas NUNCA +# pode impedir o sistema de subir. As variĂĄveis do banco chegam ao job pelo +# dotenv quando o rake carrega o Rails — o cron nĂŁo precisa herdar o ENV. +if bundle exec whenever --update-crontab && cron; then + log "cron no ar (jobs do config/schedule.rb, no fuso ${TZ:-?})" +else + log "AVISO: cron/crontab indisponĂ­vel (rode com --build) — seguindo sem agendamento" +fi + +# ── 6. Puma ────────────────────────────────────────────────────────────────── +log "subindo o Rails" +exec bundle exec rails s -b 0.0.0.0 diff --git a/bin/sondar_plano_do_dia b/bin/sondar_plano_do_dia new file mode 100755 index 0000000..877589e --- /dev/null +++ b/bin/sondar_plano_do_dia @@ -0,0 +1,149 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Descobre COMO a API do SimpliRoute expĂ”e o par veĂ­culo/ordem de um plano — que Ă© +# o que o romaneio ("CONTROLE DE ENTREGA") precisa para saber quantas folhas +# imprimir e em que sequĂȘncia. +# +# ── POR QUE ESTE SCRIPT EXISTE ────────────────────────────────────────────── +# Romaneios::PlanoDoDia tem uma escada de queda: +# (a) /v1/routes/routes/ traz vehicle + lista ordenada de visitas +# (b) a prĂłpria visita traz alguma chave de veĂ­culo/ordem +# (c) nada disso -> espelho local (db_reem_simplerout_2026), por vehicle + eta +# (d) espelho vazio -> manda o operador subir o .xlsx do plano +# As rungs (a) e (b) NUNCA foram verificadas: nĂŁo hĂĄ SIMPLIROUTE_TOKEN na mĂĄquina +# de desenvolvimento. Este script fecha isso. +# +# ── RESULTADO DA RODADA DE __/__/____ ─────────────────────────────────────── +# (preencher depois de rodar, como estĂĄ em bin/sondar_busca_nf) +# [ ] /v1/routes/routes/ existe? status: ___ rotas: ___ +# [ ] a rota traz vehicle? ___ +# [ ] a rota embute a lista de visitas? ___ +# [ ] a visita traz vehicle/order? ___ +# [ ] veĂ­culos pela API x pelo espelho: ___ x ___ +# => rung usada: ___ +# +# SÓ LEITURA: o script faz apenas GET. NĂŁo altera nada. +# +# Rode NO SERVIDOR (Ă© lĂĄ que vive o SIMPLIROUTE_TOKEN): +# +# SIMPLIROUTE_TOKEN=xxx bin/sondar_plano_do_dia --data 2026-08-31 +# +# --data precisa ser um dia com plano PUBLICADO (senĂŁo a resposta vem vazia e nĂŁo +# prova nada). +# +# ⚠ Por que olhar os VALORES e nĂŁo sĂł as chaves: a API Ă© Django REST e devolve +# chave existente com valor nil o tempo todo. "A visita tem a chave `vehicle`" NÃO +# significa que ela venha preenchida — a prova Ă© o valor. + +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 + +CHAVES_INTERESSE = /vehicle|route|driver|order|sequence|position/i.freeze + +def sair(msg) + warn msg + exit 1 +end + +sair('Defina SIMPLIROUTE_TOKEN no ambiente.') if TOKEN.empty? + +data = nil +ARGV.each_with_index { |a, i| data = ARGV[i + 1] if a == '--data' } +sair('Uso: bin/sondar_plano_do_dia --data ') if data.to_s.empty? +Date.parse(data) rescue sair("Data invĂĄlida: #{data}") + +def get(caminho) + uri = URI.join(BASE, caminho) + req = Net::HTTP::Get.new(uri) + req['Authorization'] = "Token #{TOKEN}" + req['Accept'] = 'application/json' + + inicio = Time.now + res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', + open_timeout: 10, read_timeout: 120) do |http| + http.request(req) + end + [res.code.to_i, res.body.to_s, Time.now - inicio] +rescue StandardError => e + [0, e.message, Time.now - inicio] +end + +def json(corpo) + JSON.parse(corpo) +rescue JSON::ParserError + nil +end + +def amostra(hash) + hash.select { |k, _| k.to_s.match?(CHAVES_INTERESSE) } + .map { |k, v| " #{k} = #{v.inspect.slice(0, 90)}" } +end + +puts "== SONDAGEM DO PLANO DE #{data} ==" +puts + +# ── 1. Visitas do dia ─────────────────────────────────────────────────────── +codigo, corpo, seg = get("/v1/routes/visits/?planned_date=#{data}") +puts "[1] GET /v1/routes/visits/?planned_date=#{data}" +puts " HTTP #{codigo} · #{corpo.bytesize} bytes · #{seg.round(2)}s" + +visitas = json(corpo) +sair(' Resposta nĂŁo Ă© JSON — abortando.') unless visitas.is_a?(Array) +puts " visitas: #{visitas.size}" +sair(' Nenhuma visita nessa data. Escolha um dia com plano publicado.') if visitas.empty? + +primeira = visitas.first +puts " chaves: #{primeira.keys.sort.join(', ')}" +puts ' chaves de interesse (com VALOR — chave nil nĂŁo serve):' +linhas = amostra(primeira) +puts(linhas.empty? ? ' (nenhuma)' : linhas) + +preenchidas = primeira.select { |k, v| k.to_s.match?(CHAVES_INTERESSE) && !v.nil? && v.to_s.strip != '' } +puts " => rung (b) viĂĄvel? #{preenchidas.keys.any? { |k| k.to_s.match?(/vehicle/i) } ? 'SIM' : 'NÃO'}" +puts + +# ── 2. Rotas do dia ───────────────────────────────────────────────────────── +codigo, corpo, seg = get("/v1/routes/routes/?planned_date=#{data}") +puts "[2] GET /v1/routes/routes/?planned_date=#{data}" +puts " HTTP #{codigo} · #{corpo.bytesize} bytes · #{seg.round(2)}s" + +rotas = json(corpo) +if rotas.is_a?(Array) && rotas.any? + rota = rotas.first + puts " rotas: #{rotas.size}" + puts " chaves: #{rota.keys.sort.join(', ')}" + puts ' chaves de interesse:' + puts(amostra(rota).then { |l| l.empty? ? ' (nenhuma)' : l }) + visits = rota['visits'] + puts " embute 'visits'? #{visits.nil? ? 'NÃO' : "SIM (#{Array(visits).size} itens, #{Array(visits).first.class})"}" + tem_veiculo = %w[vehicle vehicle_name vehicle_id].any? { |k| rota[k].to_s.strip != '' } + puts " => rung (a) viĂĄvel? #{tem_veiculo && !visits.nil? ? 'SIM' : 'NÃO'}" +else + puts " sem rotas utilizĂĄveis (corpo: #{corpo.slice(0, 200)})" + puts ' => rung (a) NÃO viĂĄvel' +end +puts + +# ── 3. Cruzamento com o espelho ───────────────────────────────────────────── +# Quantos veĂ­culos a API enxerga? O nĂșmero tem que bater com o do espelho local; +# se nĂŁo bater, alguma rota estĂĄ ficando de fora e o romaneio sairia incompleto. +veiculos_api = visitas.filter_map { |v| %w[vehicle vehicle_name].filter_map { |k| v[k] }.first } + .map(&:to_s).reject(&:empty?).uniq +if rotas.is_a?(Array) + veiculos_api |= rotas.filter_map { |r| %w[vehicle vehicle_name].filter_map { |k| r[k] }.first } + .map(&:to_s).reject(&:empty?) +end +puts "[3] veĂ­culos distintos vistos pela API: #{veiculos_api.size}" +puts " #{veiculos_api.sort.first(10).join(', ')}#{'
' if veiculos_api.size > 10}" +puts +puts ' Compare com o espelho, no console do app:' +puts " Entrega.da_conta_gade.no_periodo(Date.parse('#{data}'), Date.parse('#{data}'))" \ + '.distinct.count(:vehicle)' +puts +puts 'Anote o resultado no cabeçalho deste arquivo antes de commitar.' diff --git a/config/routes.rb b/config/routes.rb index 42ca49a..aa3c97f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -130,6 +130,26 @@ Rails.application.routes.draw do get :baixar end + # Romaneio "CONTROLE DE ENTREGA": uma folha A4 paisagem por veĂ­culo, assinada + # pelo motorista na retirada. O plano vem do SimpliRoute (API ou espelho de + # rastreio); o .xlsx Ă© o plano B para quando o plano do dia ainda nĂŁo foi + # publicado. As bases externas continuam somente leitura — o que gravamos sĂŁo + # as tabelas `romaneio*`, nossas. + resources :romaneios, only: %i[index show create destroy] do + collection do + post :importar_planilha # caminho B: upload do .xlsx do plano + end + member do + # ?veiculo=GADE_001 -> inline (Ă© a prĂ©via do editor); sem veiculo -> download + # do romaneio inteiro. Uma rota sĂł porque Ă© o mesmo documento. + get :pdf + post :reimportar + patch :atualizar_linha # JSON — um campo de uma parada + patch :atualizar_veiculo # JSON — motorista da folha + patch :atualizar_rotulo # JSON — o texto "PLANO:" do cabeçalho + end + end + # ── NotificaçÔes: quem recebe, o quĂȘ e por qual canal ────────────────── # Grupos e contatos sĂŁo a lista de destinatĂĄrios cadastrada Ă  mĂŁo; eventos # ligam um gatilho aos grupos; a sessĂŁo do WhatsApp Ă© o pareamento por QR. diff --git a/db/migrate/20260827000001_create_romaneios.rb b/db/migrate/20260827000001_create_romaneios.rb new file mode 100644 index 0000000..383d949 --- /dev/null +++ b/db/migrate/20260827000001_create_romaneios.rb @@ -0,0 +1,33 @@ +# Romaneio = UM plano do SimpliRoute importado para virar o "CONTROLE DE ENTREGA" +# que o motorista assina ao retirar a carga. +# +# Chave (planned_date, operacao_tabela): o eixo de busca da API Ă© a data, e a fonte +# do enriquecimento (APARELHO/telefones) Ă© exatamente UMA tabela gade_entregas_*. +# Duas operaçÔes podem dividir a mesma data com veĂ­culos distintos, daĂ­ a dupla. +# +# `rotulo_plano` ("EMAD 09.2026") Ă© APRESENTAÇÃO — sai no canto direito do PDF e o +# operador reescreve Ă  vontade. Por isso NÃO entra na chave: renomear o rĂłtulo nĂŁo +# pode criar um segundo romaneio do mesmo dia. +# +# `operacao_tabela` Ă© NOT NULL com default '' de propĂłsito: no Postgres dois NULLs +# sĂŁo distintos entre si, e um Ă­ndice Ășnico sobre coluna anulĂĄvel deixaria passar +# duplicata justamente no caso "romaneio sem operação escolhida". +class CreateRomaneios < ActiveRecord::Migration[7.1] + def change + create_table :romaneios do |t| + t.date :planned_date, null: false + t.string :operacao_tabela, null: false, default: '' + t.string :rotulo_plano, null: false, default: '' + # 'api' (rotas do SimpliRoute) | 'espelho' (db_reem_simplerout_2026) | + # 'planilha' (upload do .xlsx do plano) + t.string :origem, null: false, default: 'api' + t.datetime :importado_em + t.references :importado_por, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :romaneios, %i[planned_date operacao_tabela], + unique: true, name: 'idx_romaneios_data_operacao_unico' + end +end diff --git a/db/migrate/20260827000002_create_romaneio_veiculos.rb b/db/migrate/20260827000002_create_romaneio_veiculos.rb new file mode 100644 index 0000000..c14501e --- /dev/null +++ b/db/migrate/20260827000002_create_romaneio_veiculos.rb @@ -0,0 +1,26 @@ +# Uma FOLHA do romaneio: um veĂ­culo, uma pĂĄgina A4 paisagem. +# +# Motorista e ordem de pĂĄgina sĂŁo atributos da FOLHA, nĂŁo da linha de entrega. +# GuardĂĄ-los em cada linha significaria um UPDATE de 30 linhas a cada tecla e uma +# ambiguidade que o PDF nĂŁo sabe resolver ("qual dos 30 nomes eu imprimo?"). +# É a mesma lição de 20260703000001 (vehicle desnormalizado em consolidacao_entregas) +# → 20260703000002 (create_consolidacao_veiculos), no dia seguinte. +# +# ⚠ O nome do motorista NÃO Ă© impresso: o rodapĂ© traz a linha "MOTORISTA: ____" +# para preenchimento Ă  mĂŁo (Ă© assim que o documento Ă© assinado hoje). O campo existe +# para o operador saber, NA TELA, de quem Ă© cada carro enquanto revisa. +class CreateRomaneioVeiculos < ActiveRecord::Migration[7.1] + def change + create_table :romaneio_veiculos do |t| + t.references :romaneio, null: false, foreign_key: true + t.string :veiculo, null: false + t.string :motorista, null: false, default: '' + t.integer :posicao, null: false, default: 0 # ordem das pĂĄginas no PDF + + t.timestamps + end + + add_index :romaneio_veiculos, %i[romaneio_id veiculo], + unique: true, name: 'idx_romaneio_veiculo_unico' + end +end diff --git a/db/migrate/20260827000003_create_romaneio_linhas.rb b/db/migrate/20260827000003_create_romaneio_linhas.rb new file mode 100644 index 0000000..721ba05 --- /dev/null +++ b/db/migrate/20260827000003_create_romaneio_linhas.rb @@ -0,0 +1,51 @@ +# Uma PARADA do romaneio — vira uma linha da tabela do PDF. +# +# ⚠ "linhas" e nĂŁo "itens": "RomaneioItem".tableize dĂĄ `romaneio_items`, e o +# inflector pt-BR do projeto (config/initializers/inflections.rb) nĂŁo conhece +# item/itens — ensinĂĄ-lo renomearia todo `item`/`items` do app. `RomaneioLinha` +# pluraliza sozinho, e `linhas` jĂĄ Ă© a palavra que os serviços usam +# (PlanilhaEntregas#linhas, PlanilhaCarga#linhas). Travado em +# spec/models/table_names_spec.rb, que existe porque ConsolidacaoMotorista jĂĄ +# quebrou em produção por causa do inflector. +# +# `chave_origem` Ă© a identidade ESTÁVEL entre reimportaçÔes: a NF quando existe, +# senĂŁo "T:". Sem esse fallback, duas linhas cujo "TĂ­tulo" nĂŁo +# casa o padrĂŁo "NF n - NOME" colidiriam no Ă­ndice Ășnico e a importação inteira +# estouraria. VeĂ­culo e ordem NÃO entram na chave: eles mudam quando o plano Ă© +# reotimizado, e a linha continua sendo a mesma entrega. +# +# `origem` = snapshot do que o PLANO trouxe na Ășltima importação (para o +# diff e para o botĂŁo "restaurar do plano"). +# `campos_editados` = nomes dos campos que o operador sobrescreveu. É ISTO que +# sobrevive Ă  reimportação — dirty tracking POR CAMPO, nĂŁo por +# linha: "corrigi o telefone" nĂŁo pode significar "tambĂ©m +# respondo pelo endereço" e congelar um endereço velho. +# `removido_em` = a linha saiu do plano mas tinha edição humana. Soft, porque +# apagar levaria junto o trabalho sem deixar rastro na tela. +class CreateRomaneioLinhas < ActiveRecord::Migration[7.1] + def change + create_table :romaneio_linhas do |t| + t.references :romaneio, null: false, foreign_key: true + t.string :veiculo, null: false + t.integer :ordem, null: false, default: 0 + t.string :chave_origem, null: false + + t.string :nota_fiscal, null: false, default: '' + t.string :nome, null: false, default: '' + t.text :endereco, null: false, default: '' + t.string :aparelho, null: false, default: '' # 'SIM' ou '' + t.string :telefones, null: false, default: '' + + t.jsonb :origem, null: false, default: {} + t.jsonb :campos_editados, null: false, default: [] + t.datetime :removido_em + + t.timestamps + end + + add_index :romaneio_linhas, %i[romaneio_id chave_origem], + unique: true, name: 'idx_romaneio_linha_chave_unico' + add_index :romaneio_linhas, %i[romaneio_id veiculo ordem], + name: 'idx_romaneio_linha_ordem' + end +end diff --git a/db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb b/db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb new file mode 100644 index 0000000..d839259 --- /dev/null +++ b/db/migrate/20260827000004_add_permissao_romaneio_aos_perfis.rb @@ -0,0 +1,43 @@ +# Leva a chave nova `admin.romaneio` para os perfis de sistema JÁ SALVOS. +# +# Por que existe (mesma armadilha de 20260826000005): 20260826000003 Ă© idempotente, +# entĂŁo uma permissĂŁo acrescentada ao catĂĄlogo DEPOIS daquele deploy nunca chega Ă s +# linhas de perfis_acesso. O sintoma Ă© mudo — o item some do menu e a URL responde +# "sem permissĂŁo" sem nada explicando o porquĂȘ. +# +# Diferença deliberada para 20260826000005: aqui Ă© ADITIVO (`permissoes || '[...]'`) +# em vez de reescrever a lista inteira. Reescrever Ă© aceitĂĄvel para o Administrador +# ("acesso total" por definição); para o Gerente desfaria qualquer ajuste que o ADM +# tenha feito na tela de Perfis. +# +# Gerente entra porque `admin.romaneio` Ă© a mesma classe de ferramenta operacional +# que `admin.planilha_simpli_route`, que ele jĂĄ tem. Operador NÃO entra por padrĂŁo — +# quem carrega o caminhĂŁo recebe a chave pela tela de Perfis, que existe para isso. +class AddPermissaoRomaneioAosPerfis < ActiveRecord::Migration[7.1] + CHAVE = 'admin.romaneio'.freeze + + def up + return unless table_exists?(:perfis_acesso) + + execute(<<~SQL.squish) + UPDATE perfis_acesso + SET permissoes = permissoes || #{quote([CHAVE].to_json)}::jsonb, + updated_at = NOW() + WHERE sistema = true + AND LOWER(nome) IN ('administrador', 'gerente') + AND NOT (permissoes ? #{quote(CHAVE)}) + SQL + end + + # ReversĂ­vel: tirar a chave devolve os perfis ao estado anterior sem tocar em + # mais nada (o `-` de jsonb remove o elemento do array, se estiver lĂĄ). + def down + return unless table_exists?(:perfis_acesso) + + execute(<<~SQL.squish) + UPDATE perfis_acesso + SET permissoes = permissoes - #{quote(CHAVE)}, updated_at = NOW() + WHERE permissoes ? #{quote(CHAVE)} + SQL + end +end diff --git a/deploy/ntp-seguro.sh b/deploy/ntp-seguro.sh new file mode 100755 index 0000000..b78c97c --- /dev/null +++ b/deploy/ntp-seguro.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# RelĂłgio do SERVIDOR sincronizado com NTP autenticado (NTS). +# Rodar UMA VEZ no servidor, como root: sudo bash deploy/ntp-seguro.sh +# +# ─── POR QUE NO HOST E NÃO NO CONTAINER ────────────────────────────────────── +# Container nĂŁo tem relĂłgio prĂłprio: ele lĂȘ o clock do kernel do host. Rodar +# chrony/ntpd dentro do container exigiria CAP_SYS_TIME e mudaria a hora do +# SERVIDOR INTEIRO a partir de dentro de um processo da aplicação — por isso +# nĂŁo se faz. Dentro do container a gente fixa sĂł o FUSO (TZ, no Dockerfile); +# a HORA vem daqui. +# +# ─── POR QUE NTS E NÃO NTP COMUM ───────────────────────────────────────────── +# NTP puro Ă© UDP sem autenticação: qualquer um no caminho pode responder no +# lugar do servidor e empurrar a hora da mĂĄquina. Isso nĂŁo Ă© teĂłrico para este +# sistema — hora errada muda o dia da consolidação, o recorte do perĂ­odo +# financeiro e a validade de sessĂŁo/cookie. NTS (RFC 8915) faz uma troca de +# chaves por TLS (porta TCP 4460) e assina os pacotes NTP, entĂŁo uma resposta +# forjada Ă© descartada. O `chronyc authdata` no fim mostra se estĂĄ valendo. +# +# ─── REDE ──────────────────────────────────────────────────────────────────── +# Precisa de saĂ­da para: UDP 123 (NTP) e TCP 4460 (NTS-KE). +# Se o firewall bloquear a 4460, o NTS nĂŁo fecha e o chrony fica sem fonte. +set -euo pipefail + +[ "$(id -u)" -eq 0 ] || { echo "ERRO: rode com sudo."; exit 1; } +command -v apt-get >/dev/null || { echo "ERRO: script escrito para Debian/Ubuntu."; exit 1; } + +echo "==> Hora ANTES:"; date; echo + +# ── 1. chrony ──────────────────────────────────────────────────────────────── +# NTS sĂł existe no chrony >= 4.0 (Debian 11+ / Ubuntu 22.04+ jĂĄ tĂȘm). +if ! command -v chronyd >/dev/null; then + echo "==> Instalando chrony" + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y chrony +fi + +versao="$(chronyd --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)" +if [ "${versao%%.*}" -lt 4 ]; then + echo "ERRO: chrony $versao nĂŁo fala NTS (precisa >= 4.0). Atualize o sistema." + exit 1 +fi +echo "==> chrony $versao (NTS disponĂ­vel)" + +# ── 2. Fontes NTS ──────────────────────────────────────────────────────────── +# TrĂȘs fontes independentes de propĂłsito: com uma sĂł, ela cai e o relĂłgio fica +# Ă  deriva sem ninguĂ©m notar; com trĂȘs, o chrony ainda descarta a que divergir. +CONF_NTS="$(cat <<'CONF' +# Gerado por deploy/ntp-seguro.sh — fontes de tempo AUTENTICADAS (NTS). +# `iburst`: acerta rĂĄpido no boot, em vez de levar minutos. +server time.cloudflare.com iburst nts +server nts.netnod.se iburst nts +server ptbtime1.ptb.de iburst nts + +# Cache das chaves NTS: sem isto, todo restart refaz a troca TLS e o relĂłgio +# demora a fechar quando a internet ainda nĂŁo subiu. +ntsdumpdir /var/lib/chrony + +# Sincroniza tambĂ©m o relĂłgio de hardware (RTC), que Ă© de onde a mĂĄquina lĂȘ a +# hora ao ligar — Ă© o que evita voltar errado depois de um reboot/queda de luz. +rtcsync + +# makestep: corrige de uma vez (em vez de "escorregar" devagar) quando o erro +# Ă© grande, mas sĂł nas 3 primeiras mediçÔes. É a linha certa para servidor +# fĂ­sico. SE ESTE SERVIDOR FOR VM QUE HIBERNA/PAUSA e a hora sĂł aparece errada +# depois de uma pausa longa, troque pela linha comentada abaixo — ela permite +# o salto a qualquer momento (inclusive para trĂĄs, entĂŁo saiba o que ganha): +makestep 1.0 3 +# makestep 1.0 -1 +CONF +)" + +CHRONY_CONF=/etc/chrony/chrony.conf +[ -f "$CHRONY_CONF" ] || CHRONY_CONF=/etc/chrony.conf + +cp -n "$CHRONY_CONF" "$CHRONY_CONF.bak-ntp-seguro" 2>/dev/null || true + +# Onde escrever: o Debian/Ubuntu moderno lĂȘ o diretĂłrio conf.d. Se essa linha +# nĂŁo existir na config, escrever lĂĄ seria IGNORADO EM SILÊNCIO — nesse caso a +# gente anexa no prĂłprio chrony.conf, entre marcadores, para continuar sendo +# possĂ­vel rodar o script de novo sem duplicar. +if grep -qE '^\s*confdir\s+.*conf\.d' "$CHRONY_CONF" 2>/dev/null; then + mkdir -p /etc/chrony/conf.d + printf '%s\n' "$CONF_NTS" > /etc/chrony/conf.d/nts-reem.conf + echo "==> Fontes NTS em /etc/chrony/conf.d/nts-reem.conf" +else + sed -i '/# >>> nts-reem >>>/,/# <<< nts-reem <<>> nts-reem >>>"; printf '%s\n' "$CONF_NTS"; echo "# <<< nts-reem <<<"; } >> "$CHRONY_CONF" + echo "==> Fontes NTS anexadas em $CHRONY_CONF" +fi + +# ── 3. Desligar as fontes NÃO autenticadas ─────────────────────────────────── +# Deixar o `pool` padrĂŁo junto anularia o ganho: o chrony poderia se decidir +# por uma fonte sem assinatura. SĂł comentamos (o .bak fica ao lado). +sed -i -E 's/^([[:space:]]*(pool|server)[[:space:]]+.*)$/# [nts-reem] \1/' "$CHRONY_CONF" +sed -i -E '/# >>> nts-reem >>>/,/# <<< nts-reem <</dev/null || true +systemctl enable --now chrony 2>/dev/null || systemctl enable --now chronyd + +echo "==> Reiniciando chrony" +systemctl restart chrony 2>/dev/null || systemctl restart chronyd +sleep 5 +chronyc makestep >/dev/null 2>&1 || true + +# ── 5. ConferĂȘncia ─────────────────────────────────────────────────────────── +echo +echo "==> Fontes (o '^*' marca a que estĂĄ valendo):" +chronyc -N sources -v || true +echo +echo "==> NTS (queremos 'KeyID/Type/KLen' preenchido e Cook > 0 nas 3 linhas):" +chronyc -N authdata || true +echo +echo "==> Estado:"; chronyc tracking || true +timedatectl || true +echo +echo "==> Hora DEPOIS:"; date +cat <<'FIM' + +Pronto. O relĂłgio agora se mantĂ©m sozinho, inclusive depois de reboot. + +Se a hora tinha saltado, reinicie os containers para os processos longos +pegarem o clock corrigido: docker compose restart + +Para conferir depois, a qualquer momento: + chronyc tracking # 'System time' deve ficar em milissegundos + chronyc -N authdata # NTS ativo em cada fonte +FIM diff --git a/docker-compose.yml b/docker-compose.yml index 3996098..67c6321 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,27 +17,30 @@ services: DB_PASSWORD: ${DB_PASSWORD} SECRET_KEY_BASE: ${SECRET_KEY_BASE} RAILS_ENV: ${RAILS_ENV:-development} + # Fuso do SISTEMA OPERACIONAL dentro do container (o do Rails vem do + # config.time_zone). JĂĄ fixado no Dockerfile; repetido aqui para dar o + # ajuste sem rebuild — basta TZ=... no .env. Ver deploy/ntp-seguro.sh: + # a HORA em si Ă© do host, isto aqui sĂł define o fuso de leitura. + TZ: ${TZ:-America/Sao_Paulo} volumes: - ".:/app" - "bundle_cache:/usr/local/bundle" - # No boot/deploy, em ordem: - # 1. db:prepare — cria o banco se nĂŁo existir, roda migrations pendentes e - # sĂł faz seed se o banco acabou de ser criado (idempotente, seguro). - # 2. whenever --update-crontab — grava no crontab os jobs do config/schedule.rb - # (ex.: atualizar histĂłrico a cada 30 min, 08h-18h). - # 3. cron — sobe o daemon de cron (em background) que executa esses jobs. - # As variĂĄveis de ambiente do banco vĂȘm do .env via dotenv-rails quando - # o rake carrega o Rails, entĂŁo o cron nĂŁo precisa herdar o ENV. - # 4. rails s — sobe a aplicação (processo em primeiro plano). - # O passo do cron Ă© "best-effort": se o crontab/cron nĂŁo estiver disponĂ­vel - # (ex.: imagem antiga sem o pacote `cron`), apenas avisa no log e SEGUE — - # nunca impede o Rails de subir. (Requer rebuild: docker compose up -d --build) - # `bundle check || bundle install`: os gems ficam num VOLUME nomeado - # (bundle_cache) que persiste e SOMBREIA os gems da imagem — entĂŁo adicionar - # uma gem nova ao Gemfile nĂŁo chega no container sĂł com `up`/`--build`. Este - # passo instala o que faltar no volume no boot (idempotente e rĂĄpido quando jĂĄ - # estĂĄ tudo instalado), evitando o Bundler::GemNotFound ao subir. - command: bash -c "rm -f tmp/pids/server.pid && (bundle check || bundle install) && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0" + # Todo o boot (migrations, cron, Puma) mora em bin/docker-boot, com o + # motivo de cada passo comentado lĂĄ. Ficava aqui como uma linha sĂł de + # `bash -c`, ilegĂ­vel e impossĂ­vel de depurar quando um passo falhava. + command: ["bin/docker-boot"] + + # `unless-stopped`: sobe sozinho depois de queda do processo, de reboot do + # servidor e de `docker compose up`, e SÓ fica parado se alguĂ©m der + # `docker compose stop/down` explicitamente. Como o bin/docker-boot roda + # `db:prepare` no começo, isso jĂĄ entrega a migration automĂĄtica a cada + # restart — nĂŁo existe passo manual depois do deploy. + # + # Escolhido em vez de `always` porque `always` reergue o container atĂ© + # depois de um `stop` deliberado (num reboot do servidor), tirando do + # operador a chance de deixar o sistema fora do ar de propĂłsito. + restart: unless-stopped + # O banco PostgreSQL jĂĄ existe externamente. # Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL. # @@ -62,6 +65,9 @@ services: WHATSAPP_TOKEN: ${WHATSAPP_TOKEN} WHATSAPP_DATA_DIR: /data PORT: 3001 + # Mesmo fuso do Rails: log de envio com hora diferente da do sistema + # torna impossĂ­vel cruzar os dois na hora de investigar uma falha. + TZ: ${TZ:-America/Sao_Paulo} volumes: # A sessĂŁo pareada vive aqui. Sem este volume, cada deploy exige escanear # o QR de novo. diff --git a/public/logo-gade.png b/public/logo-gade.png new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..8594d6f0263ebf6523f1fa5cc3f9d1289275048ef8cea303674d223a4693222a GIT binary patch literal 14440 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 Ik|rVl183Mdng9R* literal 0 HcmV?d00001 diff --git a/spec/factories.rb b/spec/factories.rb index 0a4d694..87e8820 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -101,3 +101,36 @@ FactoryBot.define do entregas_contadas { 10 } end end + +FactoryBot.define do + factory :romaneio do + planned_date { Date.new(2026, 8, 31) } + operacao_tabela { '' } + rotulo_plano { 'EMAD 09.2026' } + origem { 'planilha' } + end + + factory :romaneio_linha do + romaneio + veiculo { 'GADE_001' } + sequence(:ordem) + sequence(:nota_fiscal) { |n| (90_000 + n).to_s } + chave_origem { nota_fiscal } + nome { 'FULANO DE TAL' } + endereco { 'RUA X, 1, BAIRRO, SÃO PAULO, SP' } + aparelho { '' } + telefones { '(11) 90000-0000' } + origem do + { 'nota_fiscal' => nota_fiscal, 'nome' => nome, 'endereco' => endereco, + 'aparelho' => aparelho, 'telefones' => telefones } + end + campos_editados { [] } + end + + factory :romaneio_veiculo do + romaneio + veiculo { 'GADE_001' } + motorista { '' } + posicao { 0 } + end +end diff --git a/spec/models/table_names_spec.rb b/spec/models/table_names_spec.rb index fb4faa5..97fcb1f 100644 --- a/spec/models/table_names_spec.rb +++ b/spec/models/table_names_spec.rb @@ -9,7 +9,11 @@ RSpec.describe 'Nomes de tabela dos models' do ConsolidacaoMotorista => 'consolidacao_motoristas', ConsolidacaoEntrega => 'consolidacao_entregas', Configuracao => 'configuracoes', - ConfiguracaoNotificacao => 'configuracao_notificacoes' + ConfiguracaoNotificacao => 'configuracao_notificacoes', + RomaneioVeiculo => 'romaneio_veiculos', + # "RomaneioItem" daria `romaneio_items` (o inflector pt-BR nĂŁo conhece + # item/itens); por isso a tabela Ă© `romaneio_linhas`. + RomaneioLinha => 'romaneio_linhas' }.each do |model, tabela| it "#{model} aponta para #{tabela}" do expect(model.table_name).to eq(tabela) diff --git a/spec/policies/romaneio_policy_spec.rb b/spec/policies/romaneio_policy_spec.rb new file mode 100644 index 0000000..af4f2f5 --- /dev/null +++ b/spec/policies/romaneio_policy_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe RomaneioPolicy do + subject(:policy) { described_class.new(usuario, Romaneio) } + + context 'com a permissĂŁo admin.romaneio' do + let(:usuario) { build(:gerente) } + + it { is_expected.to be_index } + it { is_expected.to be_pdf } + it { is_expected.to be_atualizar_linha } + end + + context 'sem a permissĂŁo' do + let(:usuario) { build(:operador) } + + it { is_expected.not_to be_index } + it { is_expected.not_to be_pdf } + it { is_expected.not_to be_destroy } + end +end diff --git a/spec/requests/admin/romaneios_spec.rb b/spec/requests/admin/romaneios_spec.rb new file mode 100644 index 0000000..38b043e --- /dev/null +++ b/spec/requests/admin/romaneios_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe 'Admin::Romaneios', type: :request do + let(:romaneio) { create(:romaneio) } + + describe 'permissĂŁo' do + it 'exige login' do + get admin_romaneios_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia operador (nĂŁo tem admin.romaneio por padrĂŁo)' do + sign_in create(:operador) + get admin_romaneios_path + + expect(response).to have_http_status(:redirect) + expect(flash[:alert]).to match(/permissĂŁo/i) + end + + it 'libera gerente' do + sign_in create(:gerente) + get admin_romaneios_path + + expect(response).to have_http_status(:ok) + end + end + + describe 'GET #pdf' do + before do + create(:romaneio_linha, romaneio: romaneio, veiculo: 'GADE_001') + sign_in create(:admin) + end + + it 'sai INLINE quando um veĂ­culo Ă© pedido — Ă© a prĂ©via do editor' do + get pdf_admin_romaneio_path(romaneio, veiculo: 'GADE_001') + + expect(response.media_type).to eq('application/pdf') + expect(response.headers['Content-Disposition']).to match(/inline/) + end + + it 'sai como download quando nenhum veĂ­culo Ă© pedido' do + get pdf_admin_romaneio_path(romaneio) + + expect(response.headers['Content-Disposition']).to match(/attachment/) + end + end + + describe 'PATCH #atualizar_linha' do + let(:linha) { create(:romaneio_linha, romaneio: romaneio, telefones: '(11) 1111-1111') } + + before { sign_in create(:admin) } + + it 'grava o campo e o marca como editado' do + patch atualizar_linha_admin_romaneio_path(romaneio), + params: { linha_id: linha.id, campo: 'telefones', valor: '(11) 99999-0000' }, + as: :json + + expect(response.parsed_body['valor']).to eq('(11) 99999-0000') + expect(response.parsed_body['editado']).to be(true) + expect(linha.reload.campos_editados).to include('telefones') + end + + it 'com valor null restaura o que o plano trouxe e destrava o campo' do + patch atualizar_linha_admin_romaneio_path(romaneio), + params: { linha_id: linha.id, campo: 'telefones', valor: '(11) 99999-0000' }, as: :json + + patch atualizar_linha_admin_romaneio_path(romaneio), + params: { linha_id: linha.id, campo: 'telefones', valor: nil }, as: :json + + expect(linha.reload.telefones).to eq('(11) 1111-1111') + expect(linha.campos_editados).to be_empty + end + + it 'recusa campo fora da whitelist — veiculo e ordem sĂŁo a verdade do roteiro' do + patch atualizar_linha_admin_romaneio_path(romaneio), + params: { linha_id: linha.id, campo: 'veiculo', valor: 'GADE_999' }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(linha.reload.veiculo).not_to eq('GADE_999') + end + end +end diff --git a/spec/services/pdf/romaneio_pdf_spec.rb b/spec/services/pdf/romaneio_pdf_spec.rb new file mode 100644 index 0000000..c99d6cb --- /dev/null +++ b/spec/services/pdf/romaneio_pdf_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' +# A gem entra com `require: false` no Gemfile (Ă© ferramenta de teste, nĂŁo de runtime). +require 'pdf/inspector' + +# As constantes derivadas SÃO o contrato do documento: elas foram conferidas pixel a +# pixel contra Imagens para implantação/Romaneiro_EMAD_SETEMBRO_2026.pdf (render a +# 110 dpi). Se alguĂ©m mexer numa margem "sĂł um pouquinho", Ă© aqui que aparece. +RSpec.describe Pdf::RomaneioPdf do + let(:romaneio) { create(:romaneio) } + + def semear(veiculo, quantidade) + quantidade.times do |i| + create(:romaneio_linha, romaneio: romaneio, veiculo: veiculo, ordem: i + 1) + end + end + + def paginas(pdf) + PDF::Inspector::Page.analyze(pdf).pages.size + end + + describe 'geometria' do + subject(:documento) { described_class.new(romaneio: romaneio) } + + it 'Ă© A4 paisagem' do + medidas = documento.instance_variable_get(:@pdf).bounds + expect([medidas.width.round(2), medidas.height.round(2)]).to eq([785.2, 487.56]) + end + + it 'mantĂ©m a altura de linha e os corpos de fonte do documento original' do + expect(documento.instance_variable_get(:@linha_h)).to be_within(0.001).of(11.1811) + expect(documento.instance_variable_get(:@fonte_dados)).to eq(7.0) + expect(documento.instance_variable_get(:@fonte_hdr)).to eq(7.5) + end + + it 'usa a largura Ăștil inteira nas seis colunas' do + larguras = documento.instance_variable_get(:@larguras) + expect(larguras.size).to eq(6) + expect(larguras.sum).to be_within(0.01).of(785.2) + end + + it 'reserva espaço para o rodapĂ© caber na folha cheia' do + # 33 paradas + as 2 linhas em branco estouravam o orçamento e o + # "ASSINATURA DO MOTORISTA" sumia do papel, sem erro nenhum. + expect(documento.instance_variable_get(:@max_linhas_folha)) + .to be >= described_class::MAX_LINHAS + end + end + + it 'gera uma pĂĄgina por veĂ­culo' do + semear('GADE_001', 5) + semear('GADE_002', 3) + + expect(paginas(described_class.new(romaneio: romaneio).render)).to eq(2) + end + + it 'imprime sĂł o veĂ­culo pedido (Ă© o que a prĂ©via do editor usa)' do + semear('GADE_001', 5) + semear('GADE_002', 3) + + expect(paginas(described_class.new(romaneio: romaneio, veiculos: ['GADE_002']).render)).to eq(1) + end + + it 'quebra em folhas de continuação acima de MAX_LINHAS' do + semear('GADE_008', described_class::MAX_LINHAS + 1) + + pdf = described_class.new(romaneio: romaneio).render + expect(paginas(pdf)).to eq(2) + texto = PDF::Inspector::Text.analyze(pdf).strings.join(' ') + expect(texto).to include('FOLHA 1/2').and include('FOLHA 2/2') + end + + it 'assina TODAS as folhas — inclusive a de continuação' do + semear('GADE_008', described_class::MAX_LINHAS + 1) + + texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings + expect(texto.count { |t| t.include?('ASSINATURA DO MOTORISTA') }).to eq(2) + end + + it 'numera as paradas continuando entre folhas (bate com a ordem no celular)' do + semear('GADE_008', described_class::MAX_LINHAS + 1) + + texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings + expect(texto).to include((described_class::MAX_LINHAS + 1).to_s) + end + + it 'nĂŁo imprime a parada que saiu do plano' do + semear('GADE_001', 3) + romaneio.romaneio_linhas.first.update!(removido_em: Time.current, nome: 'NAO DEVE SAIR') + + texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings.join(' ') + expect(texto).not_to include('NAO DEVE SAIR') + end + + it 'escreve o rĂłtulo do plano no cabeçalho' do + semear('GADE_001', 2) + + texto = PDF::Inspector::Text.analyze(described_class.new(romaneio: romaneio).render).strings.join(' ') + expect(texto).to include('PLANO:').and include('EMAD 09.2026') + end + + it 'aguenta acento nos endereços (a fonte AFM do Prawn estouraria aqui)' do + create(:romaneio_linha, romaneio: romaneio, veiculo: 'GADE_001', + endereco: 'RUA INÁCIO DE OLIVEIRA CAMPOS, 182, SÃO PAULO') + + expect { described_class.new(romaneio: romaneio).render }.not_to raise_error + end +end diff --git a/spec/services/romaneios/enriquecimento_gade_spec.rb b/spec/services/romaneios/enriquecimento_gade_spec.rb new file mode 100644 index 0000000..76c4d5e --- /dev/null +++ b/spec/services/romaneios/enriquecimento_gade_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +# É o que dispensa a SEGUNDA PLANILHA do programa Python: o STATUS (NOVO/RECORRENTE) +# jĂĄ estĂĄ na tabela da operação. +RSpec.describe Romaneios::EnriquecimentoGade do + include_context 'tabela de operação' + + let(:tabela) { "#{Operacao::PREFIXO}teste_jan_2026" } + + it 'marca APARELHO = SIM sĂł para quem estĂĄ como NOVO' do + criar_operacao!(tabela) + semear_operacao!(tabela, [ + { nota_fiscal: '1', nome_completo: 'Novo Paciente', telefones: '(11) 1', + status: 'NOVO' }, + { nota_fiscal: '2', nome_completo: 'Velho Paciente', telefones: '(11) 2', + status: 'RECORRENTE' } + ]) + + dados = described_class.new(tabela).para_notas(%w[1 2]) + + expect(dados['1'][:aparelho]).to eq('SIM') + expect(dados['2'][:aparelho]).to eq('') + expect(dados['1'][:nome]).to eq('NOVO PACIENTE') + expect(dados['2'][:telefones]).to eq('(11) 2') + end + + it 'nĂŁo quebra quando a operação nĂŁo tem a coluna status — e AVISA qual falta' do + criar_operacao!(tabela, colunas: %i[nome_completo telefones]) + semear_operacao!(tabela, [{ nota_fiscal: '1', nome_completo: 'Fulano', telefones: '(11) 1' }]) + + servico = described_class.new(tabela) + + expect(servico.colunas_ausentes).to eq(['status']) + expect(servico.para_notas(['1'])['1'][:aparelho]).to eq('') + end + + it 'omite a NF que nĂŁo estĂĄ na operação (Ă© o plano avulso / de inclusĂŁo)' do + criar_operacao!(tabela) + semear_operacao!(tabela, [{ nota_fiscal: '1', status: 'NOVO' }]) + + expect(described_class.new(tabela).para_notas(%w[1 999])).not_to have_key('999') + end + + it 'recusa nome de tabela fora da whitelist de operaçÔes' do + expect { described_class.new('users; DROP TABLE users') }.to raise_error(ArgumentError) + end +end diff --git a/spec/services/romaneios/importador_spec.rb b/spec/services/romaneios/importador_spec.rb new file mode 100644 index 0000000..63ae6a0 --- /dev/null +++ b/spec/services/romaneios/importador_spec.rb @@ -0,0 +1,115 @@ +require 'rails_helper' + +# As regras de reconciliação — Ă© onde mora o risco da funcionalidade: errar aqui +# significa ou perder a correção que o operador fez Ă  mĂŁo, ou imprimir um endereço +# velho que o SimpliRoute jĂĄ corrigiu. +RSpec.describe Romaneios::Importador do + let(:romaneio) { create(:romaneio) } + + def linha_do_plano(nf:, nome: 'FULANO', endereco: 'RUA A, 1', telefones: '(11) 1111-1111', + veiculo: 'GADE_001', ordem: 1) + { veiculo: veiculo, ordem: ordem, motorista: '', nota_fiscal: nf, nome: nome, + endereco: endereco, telefones: telefones, aparelho: '', tracking_id: '', + titulo: "NF #{nf} - #{nome}" } + end + + def importar(linhas) + described_class.new(romaneio: romaneio, linhas: linhas, origem: 'planilha').aplicar! + end + + it 'insere as paradas novas e cria a folha de cada veĂ­culo' do + resultado = importar([linha_do_plano(nf: '1'), + linha_do_plano(nf: '2', veiculo: 'GADE_002', ordem: 1)]) + + expect(resultado.novos).to eq(2) + expect(romaneio.romaneio_linhas.count).to eq(2) + expect(romaneio.romaneio_veiculos.pluck(:veiculo)).to contain_exactly('GADE_001', 'GADE_002') + end + + it 'PRESERVA o campo que o operador editou e atualiza os demais' do + importar([linha_do_plano(nf: '1', nome: 'FULANO', endereco: 'RUA A, 1')]) + linha = romaneio.romaneio_linhas.first + linha.telefones = '(11) 99999-0000' + linha.marcar_editado!('telefones') + linha.save! + + resultado = importar([linha_do_plano(nf: '1', nome: 'FULANO DE TAL', + endereco: 'RUA B, 2', telefones: '(11) 1111-1111')]) + + linha.reload + expect(linha.telefones).to eq('(11) 99999-0000') # intocado: tem edição humana + expect(linha.endereco).to eq('RUA B, 2') # o plano manda no resto + expect(resultado.preservados).to eq(1) + expect(resultado.atualizados).to eq(1) + end + + it 'faz veĂ­culo e ordem seguirem o plano mesmo numa linha editada' do + importar([linha_do_plano(nf: '1', veiculo: 'GADE_001', ordem: 1)]) + linha = romaneio.romaneio_linhas.first + linha.marcar_editado!('nome') + linha.save! + + importar([linha_do_plano(nf: '1', veiculo: 'GADE_009', ordem: 7)]) + + expect(linha.reload.veiculo).to eq('GADE_009') + expect(linha.ordem).to eq(7) + end + + it 'APAGA a parada que saiu do plano sem edição humana' do + importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)]) + + resultado = importar([linha_do_plano(nf: '1')]) + + expect(romaneio.romaneio_linhas.pluck(:nota_fiscal)).to eq(['1']) + expect(resultado.saidos).to eq(1) + end + + it 'GUARDA (sem apagar) a parada que saiu do plano mas tinha edição' do + importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)]) + editada = romaneio.romaneio_linhas.find_by(nota_fiscal: '2') + editada.marcar_editado!('endereco') + editada.save! + + importar([linha_do_plano(nf: '1')]) + + expect(editada.reload.removido_em).to be_present + expect(romaneio.linhas_vigentes.pluck(:nota_fiscal)).to eq(['1']) + end + + it 'devolve a parada guardada para a folha quando ela volta ao plano' do + importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)]) + editada = romaneio.romaneio_linhas.find_by(nota_fiscal: '2') + editada.marcar_editado!('endereco') + editada.save! + importar([linha_do_plano(nf: '1')]) + + importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', ordem: 2)]) + + expect(editada.reload.removido_em).to be_nil + end + + it 'descarta a NF repetida no plano e reporta, em vez de duplicar a parada' do + resultado = importar([linha_do_plano(nf: '1', nome: 'PRIMEIRO'), + linha_do_plano(nf: '1', nome: 'SEGUNDO', ordem: 2)]) + + expect(romaneio.romaneio_linhas.count).to eq(1) + expect(romaneio.romaneio_linhas.first.nome).to eq('PRIMEIRO') + expect(resultado.duplicados).to eq(1) + end + + it 'nĂŁo colide duas paradas sem NF (a chave cai para o digest do tĂ­tulo)' do + sem_nf = linha_do_plano(nf: '').merge(titulo: 'PONTO DE APOIO') + outra = linha_do_plano(nf: '', endereco: 'RUA Z, 9', ordem: 2).merge(titulo: 'OUTRO PONTO') + + expect { importar([sem_nf, outra]) }.not_to raise_error + expect(romaneio.romaneio_linhas.count).to eq(2) + end + + it 'apaga a folha do veĂ­culo que sumiu do plano' do + importar([linha_do_plano(nf: '1'), linha_do_plano(nf: '2', veiculo: 'GADE_002')]) + + importar([linha_do_plano(nf: '1')]) + + expect(romaneio.romaneio_veiculos.pluck(:veiculo)).to eq(['GADE_001']) + end +end diff --git a/spec/services/romaneios/plano_planilha_spec.rb b/spec/services/romaneios/plano_planilha_spec.rb new file mode 100644 index 0000000..a57a1f9 --- /dev/null +++ b/spec/services/romaneios/plano_planilha_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' +require 'caxlsx' + +# A planilha do plano Ă© o caminho B do romaneio — e Ă© o que o cliente usa quando o +# plano do dia ainda nĂŁo estĂĄ publicado. Os casos aqui sĂŁo os que o export REAL +# apresenta (medido em Imagens para implantação/SimpliRoute_Plan_ 2026-08-31.xlsx): +# coluna "Motorista" vazia, sem "Aparelho", e a NF numa coluna com typo do prĂłprio +# SimpliRoute ("ID de referĂȘncua"). +RSpec.describe Romaneios::PlanoPlanilha do + def planilha(cabecalho, linhas) + pacote = Axlsx::Package.new + pacote.workbook.add_worksheet(name: 'Plan') do |aba| + aba.add_row(cabecalho) + linhas.each { |l| aba.add_row(l) } + end + arquivo = Tempfile.new(['plano', '.xlsx']) + arquivo.binmode + arquivo.write(pacote.to_stream.read) + arquivo.flush + arquivo + end + + it 'lĂȘ o formato real do export (sem Motorista, sem Aparelho, NF com o typo)' do + arquivo = planilha( + ['Motorista', 'VeĂ­culo', 'Índice de rota', 'Ordem de entrega', 'TĂ­tulo', 'Endereço', + 'ID de referĂȘncua', 'AnotaçÔes'], + [[nil, 'GADE_038', 1, 1, 'NF 90507 - Cicera Beserra dos Anjos', + 'Rua Amapola, 206, Jardim Vista Linda, SĂŁo Paulo, SP, CEP: 05159-100', + '90507', 'TEL: (11) 99262-0594']] + ) + + linha = described_class.new(arquivo).linhas.first + + expect(linha[:veiculo]).to eq('GADE_038') + expect(linha[:nota_fiscal]).to eq('90507') + expect(linha[:nome]).to eq('CICERA BESERRA DOS ANJOS') + expect(linha[:endereco]).to eq('RUA AMAPOLA, 206, JARDIM VISTA LINDA, SÃO PAULO, SP, CEP: 05159-100') + expect(linha[:telefones]).to eq('(11) 99262-0594') + expect(linha[:motorista]).to eq('') + end + + it 'aceita a grafia corrigida da coluna de referĂȘncia' do + arquivo = planilha(['VeĂ­culo', 'TĂ­tulo', 'Endereço', 'ID de referĂȘncia'], + [['GADE_001', 'NF 1 - Fulano', 'Rua A, 1', '1']]) + + expect(described_class.new(arquivo).linhas.first[:nota_fiscal]).to eq('1') + end + + it 'aceita tĂ­tulo fora do padrĂŁo "NF n - NOME" sem perder a parada' do + arquivo = planilha(['VeĂ­culo', 'TĂ­tulo', 'Endereço'], + [['GADE_001', 'PONTO DE APOIO', 'Rua A, 1']]) + + linha = described_class.new(arquivo).linhas.first + expect(linha[:nota_fiscal]).to eq('') + expect(linha[:nome]).to eq('PONTO DE APOIO') + end + + it 'tira o sufixo ", Brazil" do endereço e junta vĂĄrios telefones' do + arquivo = planilha(['VeĂ­culo', 'TĂ­tulo', 'Endereço', 'AnotaçÔes'], + [['GADE_001', 'NF 1 - Fulano', 'Rua A, 1, Bairro, Brazil', + 'TEL: (11) 95440-5780 / (11) 99824-8099']]) + + linha = described_class.new(arquivo).linhas.first + expect(linha[:endereco]).to eq('RUA A, 1, BAIRRO') + expect(linha[:telefones]).to eq('(11) 95440-5780 / (11) 99824-8099') + end + + it 'renumera a ordem de 1..N dentro de cada veĂ­culo, na ordem de entrega do plano' do + arquivo = planilha(['VeĂ­culo', 'Ordem de entrega', 'TĂ­tulo', 'Endereço'], + [['GADE_001', 3, 'NF 3 - C', 'Rua C'], + ['GADE_002', 9, 'NF 9 - I', 'Rua I'], + ['GADE_001', 1, 'NF 1 - A', 'Rua A']]) + + linhas = described_class.new(arquivo).linhas + primeiro = linhas.select { |l| l[:veiculo] == 'GADE_001' } + expect(primeiro.map { |l| [l[:ordem], l[:nota_fiscal]] }).to eq([[1, '1'], [2, '3']]) + expect(linhas.find { |l| l[:veiculo] == 'GADE_002' }[:ordem]).to eq(1) + end + + it 'pula linha sem veĂ­culo (rodapĂ© do prĂłprio export) sem levantar erro' do + arquivo = planilha(['VeĂ­culo', 'TĂ­tulo', 'Endereço'], + [['GADE_001', 'NF 1 - A', 'Rua A'], [nil, nil, nil]]) + + expect(described_class.new(arquivo).linhas.size).to eq(1) + end + + it 'recusa arquivo que nĂŁo Ă© .xlsx com uma mensagem acionĂĄvel' do + arquivo = Tempfile.new(['plano', '.xlsx']) + arquivo.write('isto nĂŁo Ă© um zip') + arquivo.flush + + expect { described_class.new(arquivo) } + .to raise_error(described_class::Erro, /nĂŁo Ă© um \.xlsx/) + end + + it 'recusa planilha sem a coluna VeĂ­culo — nĂŁo dĂĄ para montar folha sem carro' do + arquivo = planilha(['TĂ­tulo', 'Endereço'], [['NF 1 - A', 'Rua A']]) + + expect { described_class.new(arquivo).linhas } + .to raise_error(described_class::Erro, /VeĂ­culo/) + end +end diff --git a/spec/support/tabela_operacao.rb b/spec/support/tabela_operacao.rb new file mode 100644 index 0000000..968d9f8 --- /dev/null +++ b/spec/support/tabela_operacao.rb @@ -0,0 +1,84 @@ +# Harness de uma tabela de operação (gade_entregas_*) para os specs. +# +# A tabela Ă© EXTERNA: nĂŁo tem migration (o cliente a carrega no banco) e nĂŁo tem +# factory. Sem este harness, testar o enriquecimento do romaneio sĂł daria para +# mockar o mĂ©todo sob teste — o que nĂŁo pega regressĂŁo de SQL, que Ă© onde moram os +# bugs de coluna ausente e de alias (`status` da gade x `status` do espelho). +# +# Guardas copiadas de spec/support/espelho_rastreio.rb, pelo mesmo motivo: se a +# tabela jĂĄ existir COM DADOS, estamos apontados para dados de verdade e o spec tem +# que abortar em vez de destruĂ­-los. +# +# Uso: +# +# RSpec.describe Romaneios::EnriquecimentoGade do +# include_context 'tabela de operação' +# +# it '...' do +# criar_operacao!('gade_entregas_teste_jan_2026', colunas: %i[nome_completo telefones status]) +# semear_operacao!('gade_entregas_teste_jan_2026', [{ nota_fiscal: '1', status: 'NOVO' }]) +# end +# end +module TabelaOperacao + COLUNAS_PADRAO = %i[nome_completo telefones status].freeze + + class << self + def criar!(nome, colunas: COLUNAS_PADRAO) + raise "Tabela de operação de teste exige RAILS_ENV=test (estĂĄ em #{Rails.env})" unless Rails.env.test? + + if conexao.table_exists?(nome) + linhas = conexao.select_value("SELECT COUNT(*) FROM #{conexao.quote_table_name(nome)}").to_i + if linhas.positive? + raise "#{nome} jĂĄ existe no banco de teste com #{linhas} linha(s) — o spec NÃO vai " \ + 'derrubĂĄ-la. Aponte RAILS_ENV=test para um banco descartĂĄvel.' + end + conexao.drop_table(nome) + end + + conexao.create_table(nome, id: false) do |t| + t.string :nota_fiscal + colunas.each { |c| t.string c } + end + criadas << nome + nome + end + + def semear!(nome, linhas) + colunas = conexao.columns(nome).map(&:name) + linhas.each do |linha| + dados = linha.transform_keys(&:to_s).slice(*colunas) + conexao.execute( + "INSERT INTO #{conexao.quote_table_name(nome)} (#{dados.keys.join(', ')}) " \ + "VALUES (#{dados.values.map { |v| conexao.quote(v) }.join(', ')})" + ) + end + end + + def derrubar_todas! + criadas.each { |nome| conexao.drop_table(nome, if_exists: true) } + criadas.clear + end + + def criadas + @criadas ||= [] + end + + private + + def conexao + ActiveRecord::Base.connection + end + end +end + +RSpec.shared_context 'tabela de operação' do + after { TabelaOperacao.derrubar_todas! } + + def criar_operacao!(nome, colunas: TabelaOperacao::COLUNAS_PADRAO) + TabelaOperacao.criar!(nome, colunas: colunas) + end + + def semear_operacao!(nome, linhas) + TabelaOperacao.semear!(nome, linhas) + end +end diff --git a/whatsapp/Dockerfile b/whatsapp/Dockerfile index 2e4316f..f693397 100644 --- a/whatsapp/Dockerfile +++ b/whatsapp/Dockerfile @@ -9,7 +9,10 @@ WORKDIR /app # registry do npm. Sem git na imagem o install morre logo no inicio com # `npm error syscall spawn git / ENOENT` — nao e erro de rede nem de versao. # python3/make/g++: node-gyp, para as dependencias nativas de cripto. -RUN apk add --no-cache git python3 make g++ +# tzdata: o Alpine nao traz zoneinfo. Sem ele, TZ=America/Sao_Paulo e ignorado +# em silencio e o log da ponte fica 3h adiantado (UTC), atrapalhando o +# cruzamento com o log do Rails na hora de investigar um envio. +RUN apk add --no-cache git python3 make g++ tzdata COPY package.json ./ # --no-audit --no-fund: so tiram ruido do log de build, que e onde se enxerga o @@ -23,5 +26,9 @@ COPY server.js ./ VOLUME ["/data"] ENV WHATSAPP_DATA_DIR=/data +# Mesmo fuso do container do Rails — ver o comentario no Dockerfile da raiz. +ENV TZ=America/Sao_Paulo +RUN ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && echo "$TZ" > /etc/timezone + EXPOSE 3001 CMD ["node", "server.js"] -- 2.49.1 From 5a438d981252730ab6d4fadcd59ea557b5b8e2627ade1be916e3589eb1139831 Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 17:16:17 -0300 Subject: [PATCH 22/40] Corrige boot: storage.yml versionado e bin/docker-boot via bash --- Dockerfile | 3 ++- config/storage.yml | 26 ++++++++++++++++++++++++++ docker-compose.yml | 7 ++++++- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 config/storage.yml diff --git a/Dockerfile b/Dockerfile index f97f83d..b9b4c01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,4 +48,5 @@ EXPOSE 3000 # O boot real (migrations, cron, servidor) mora em bin/docker-boot — ver os # comentĂĄrios lĂĄ. CMD aqui Ă© sĂł o fallback de quem roda a imagem sem o compose. -CMD ["bin/docker-boot"] +# (via `bash` — ver o motivo no docker-compose.yml) +CMD ["bash", "bin/docker-boot"] diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..669d513 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,26 @@ +# Serviços de armazenamento do Active Storage. +# +# POR QUE ESTE ARQUIVO É VERSIONADO (e por que o boot morre sem ele): +# `config/application.rb` faz `require "rails/all"`, entĂŁo o engine do Active +# Storage SEMPRE carrega, e os dois `config/environments/*.rb` definem +# `active_storage.service = :local`. Com um serviço definido, o Rails exige este +# arquivo durante o `initialize!` e derruba o servidor com: +# Couldn't find Active Storage configuration in /app/config/storage.yml +# +# Ele nunca esteve no git — existia criado Ă  mĂŁo em cada servidor. Em 27/08/2026 +# a pasta do deploy foi recriada e o arquivo se perdeu junto com o `.env`, +# derrubando o boot depois das migrations. NĂŁo hĂĄ segredo nenhum aqui (sĂł +# caminhos de disco), entĂŁo o lugar dele Ă© no repositĂłrio. +# +# ⚠ Se um dia o app deixar de usar Active Storage, o certo Ă© remover as linhas +# `active_storage.service` dos environments — nĂŁo apagar este arquivo. + +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +# A pasta `storage/` jĂĄ existe no repo (com `.keep`); o CONTEÚDO Ă© ignorado pelo +# git (`.gitignore`: `/storage/*`), como deve ser. +local: + service: Disk + root: <%= Rails.root.join("storage") %> diff --git a/docker-compose.yml b/docker-compose.yml index 67c6321..df3d292 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,12 @@ services: # Todo o boot (migrations, cron, Puma) mora em bin/docker-boot, com o # motivo de cada passo comentado lĂĄ. Ficava aqui como uma linha sĂł de # `bash -c`, ilegĂ­vel e impossĂ­vel de depurar quando um passo falhava. - command: ["bin/docker-boot"] + # + # `bash` explĂ­cito de propĂłsito, em vez de chamar o script direto: a pasta + # do projeto vive num share do NAS e o BIT DE EXECUÇÃO se perde ao copiar + # por SMB/File Station. Sem isto, o deploy morreria com um + # "permission denied" que nĂŁo tem nada a ver com o script. + command: ["bash", "bin/docker-boot"] # `unless-stopped`: sobe sozinho depois de queda do processo, de reboot do # servidor e de `docker compose up`, e SÓ fica parado se alguĂ©m der -- 2.49.1 From a06f93784c6f7314ec236e20ec6234ba40889c1fd0a4110037e694a0bb26debc Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 18:03:49 -0300 Subject: [PATCH 23/40] Corrige boot: storage.yml versionado e bin/docker-boot via bash --- .env.example | 8 ++++++++ docker-compose.yml | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 49b6b94..e7a8efb 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,14 @@ RAILS_ENV=production # do HOST: rode uma vez `sudo bash deploy/ntp-seguro.sh`. TZ=America/Sao_Paulo +# ── Porta publicada no host ─────────────────────────────── +# A porta DENTRO do container Ă© sempre 3000; esta Ă© a do servidor. +# SĂł precisa mudar quando outro stack jĂĄ ocupa a porta no mesmo host — +# Ă© o caso do ambiente de TESTE, que usa 3001 (a 3000 estĂĄ ocupada). +# Sem esta variĂĄvel o compose assume 3000. +# ⚠ Tem que casar com o destino do proxy reverso (DSM / Cloudflare). +PORTA_APP=3000 + # ── Banco de dados (seu PostgreSQL jĂĄ existente) ───────────── # Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db diff --git a/docker-compose.yml b/docker-compose.yml index df3d292..00c9a9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,23 @@ services: build: context: . dockerfile: Dockerfile + # PORTA DO HOST parametrizada — a do container Ă© sempre 3000. + # + # POR QUE NÃO É FIXA: o mesmo NAS roda mais de um stack, e dois containers + # nĂŁo publicam a mesma porta ("driver failed programming external + # connectivity ... port is already allocated"). O ambiente de TESTE usa a + # 3001; a 3000 jĂĄ estĂĄ ocupada. + # + # Isto vivia como alteração LOCAL no docker-compose.yml do servidor — e em + # 27/08/2026 um `git reset --hard` levou junto, derrubando o ambiente com o + # proxy apontando para uma porta sem ninguĂ©m escutando. Parametrizado aqui, + # a porta passa a morar no .env (que o reset nĂŁo toca) e o arquivo fica + # igual em todos os ambientes. + # + # ⚠ Ao mudar, ajuste JUNTO o proxy reverso (DSM/Cloudflare) — senĂŁo o 502 + # sĂł muda de lugar. ports: - - "3000:3000" + - "${PORTA_APP:-3000}:3000" env_file: .env environment: DATABASE_URL: ${DATABASE_URL} -- 2.49.1 From 7fa2336b35f2a7e265b15b349177fd7d5b1585fc6c6389c8bebbd7a3dcf5f818 Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 18:30:17 -0300 Subject: [PATCH 24/40] =?UTF-8?q?Corrige=20acesso=20em=20produ=C3=A7=C3=A3?= =?UTF-8?q?o:=20hosts=20permitidos,=20i18n=20do=20Devise=20e=20config=20do?= =?UTF-8?q?=20ambiente=20no=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - production.rb (hosts): a linha que parecia sĂł liberar o APP_HOST na verdade LIGAVA a verificação de Host e recusava todo o resto — o acesso pelo IP interno caĂ­a em 'Blocked hosts: 100.75.222.23:3001'. Agora entram tambĂ©m localhost/127.0.0.1 e a lista HOSTS_PERMITIDOS. - production.rb (i18n): 'fallbacks = true' significa cair no default_locale, que aqui Ă© o prĂłprio pt-BR — o fallback apontava para si mesmo e chave ausente virava 'Translation missing' na tela do usuĂĄrio. Agora [:en], igual ao application.rb. - pt-BR.yml: traduçÔes do Devise (failure/sessions/passwords). O projeto nĂŁo usa devise-i18n; em development o texto vinha em inglĂȘs pelo fallback e o buraco passava despercebido. - docker-compose.yml / .env.example: porta do host parametrizada (${PORTA_APP:-3001}) e APP_HOST/APP_NAME/WHATSAPP_URL/HOSTS_PERMITIDOS com padrĂŁo — essa configuração vivia sĂł no .env do servidor e sumiu junto com ele. --- .env.example | 22 ++++++++++++---- config/environments/production.rb | 35 ++++++++++++++++++++++-- config/locales/pt-BR.yml | 44 +++++++++++++++++++++++++++++-- docker-compose.yml | 25 ++++++++++++++++-- 4 files changed, 115 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index e7a8efb..0f1ec35 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,11 @@ TZ=America/Sao_Paulo # ── Porta publicada no host ─────────────────────────────── # A porta DENTRO do container Ă© sempre 3000; esta Ă© a do servidor. -# SĂł precisa mudar quando outro stack jĂĄ ocupa a porta no mesmo host — -# Ă© o caso do ambiente de TESTE, que usa 3001 (a 3000 estĂĄ ocupada). -# Sem esta variĂĄvel o compose assume 3000. +# Neste branch (teste) o padrĂŁo do docker-compose.yml JÁ É 3001, porque a 3000 +# estĂĄ ocupada por outro stack no mesmo NAS. Esta linha sĂł Ă© necessĂĄria para +# usar uma porta diferente da padrĂŁo. # ⚠ Tem que casar com o destino do proxy reverso (DSM / Cloudflare). -PORTA_APP=3000 +PORTA_APP=3001 # ── Banco de dados (seu PostgreSQL jĂĄ existente) ───────────── # Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL @@ -91,9 +91,21 @@ SMTP_DOMAIN=gade.com.br NOTIFICACAO_SECRET= # ── App ─────────────────────────────────────────────────────── -APP_HOST=localhost:3000 +# ⚠ SÓ O HOST, sem "https://". O cĂłdigo monta os links como +# "https://#{APP_HOST}/motorista" (consolidacao_mailer.rb, +# notificacao_service.rb, gatilhos.rb): com o esquema aqui sai +# "https://https://..." e todo link de e-mail e de WhatsApp quebra. +# O docker-compose.yml jĂĄ traz o host do ambiente como padrĂŁo — esta linha sĂł +# Ă© necessĂĄria para apontar para outro endereço. +APP_HOST=teste.reemtransportes.com.br APP_NAME=Reem LogĂ­stica +# Hosts extras aceitos alĂ©m do APP_HOST, separados por vĂ­rgula (sĂł vale com +# RAILS_ENV=production, onde o Rails checa o cabeçalho Host). +# Use quando o sistema tambĂ©m for acessado por IP:porta na rede interna — +# sem isso o Rails responde "Blocked hosts: :". +HOSTS_PERMITIDOS=100.75.222.23 + # ── WhatsApp (ponte prĂłpria — sessĂŁo pareada por QR code) ────────────────── # Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string # longa e aleatĂłria: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO. diff --git a/config/environments/production.rb b/config/environments/production.rb index ea00f54..7eeee1a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -21,11 +21,42 @@ Rails.application.configure do config.action_mailer.perform_caching = false config.action_mailer.default_url_options = { host: ENV.fetch("APP_HOST", "localhost:3000") } - config.i18n.fallbacks = true + # `= true` significa "caia no default_locale" — e o default AQUI Ă© o prĂłprio + # pt-BR, entĂŁo o fallback apontava para si mesmo e qualquer chave ausente + # virava "Translation missing" NA TELA DO USUÁRIO (aconteceu em 27/08/2026 na + # tela de login, com devise.failure.unauthenticated). Em development o + # config/application.rb jĂĄ usa [:en] e por isso o problema nĂŁo aparecia lĂĄ. + # Mantido igual ao application.rb: chave faltando mostra o texto em inglĂȘs, + # que Ă© feio mas legĂ­vel — nunca a mensagem de erro do i18n. + config.i18n.fallbacks = [:en] config.active_support.report_deprecations = false config.active_record.dump_schema_after_migration = false - # Aceita o host configurado no .env + # ── Hosts aceitos (proteção contra DNS rebinding) ───────────────────────── + # Regra do Rails: lista VAZIA = aceita qualquer Host; lista com QUALQUER item + # = sĂł o que estiver nela entra. Por isso a linha abaixo, que parece sĂł + # "liberar o APP_HOST", na verdade LIGA a verificação e bloqueia todo o resto + # com "ActionDispatch::HostAuthorization ... Blocked hosts: ". + # + # ⚠ APP_HOST tem que vir SEM "https://": com o esquema, este split libera um + # host chamado "https" e nada funciona. config.hosts << ENV["APP_HOST"]&.split(":")&.first if ENV["APP_HOST"].present? + + # O prĂłprio servidor. Sem isto, um `curl http://127.0.0.1:3001/` de dentro do + # NAS — que Ă© o teste que separa "app caiu" de "proxy errado" — volta + # bloqueado e faz parecer que a aplicação estĂĄ quebrada. + config.hosts += ["localhost", "127.0.0.1", "[::1]"] + + # Endereços extras, separados por vĂ­rgula. Caso real (27/08/2026): o acesso + # pela rede interna chega com `Host: 100.75.222.23:3001` (o IP do NAS), que + # nĂŁo bate com o domĂ­nio pĂșblico e caĂ­a em "Blocked hosts". A porta Ă© + # ignorada pelo Rails na comparação — basta o IP/nome. + # HOSTS_PERMITIDOS=100.75.222.23,10.0.0.20 + ENV.fetch("HOSTS_PERMITIDOS", "").split(",").map(&:strip).reject(&:empty?).each do |permitido| + config.hosts << permitido + end + + # Escotilha de emergĂȘncia: desliga a verificação por completo. Último recurso — + # com ela ligada, qualquer domĂ­nio apontado para este servidor Ă© aceito. config.hosts.clear if ENV["RAILS_ALLOW_ALL_HOSTS"] == "true" end diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index d43cf2a..0162c57 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -8,8 +8,48 @@ pt-BR: default: "%d/%m/%Y %H:%M" short: "%d/%m %H:%M" - # Mensagem de login recusado para conta desativada (User#inactive_message). - # Sem esta chave, o Devise mostraria o sĂ­mbolo cru "conta_desativada". + # ── Devise ──────────────────────────────────────────────────────────────── + # POR QUE ESTÁ TUDO AQUI E NÃO SÓ A CHAVE DA CONTA DESATIVADA: + # o projeto nĂŁo usa a gem `devise-i18n`, entĂŁo as Ășnicas traduçÔes do Devise + # sĂŁo estas. Em development a ausĂȘncia passava despercebida porque o + # config/application.rb faz `i18n.fallbacks = [:en]` e o texto vinha em inglĂȘs + # do prĂłprio gem. Em produção o `fallbacks` apontava para o pt-BR (ele mesmo) + # e a tela de login exibiu, para o usuĂĄrio final: + # "Translation missing. Options considered were: pt-BR.devise.failure.unauthenticated" + # Traduzir de fato Ă© melhor do que cair no inglĂȘs: quem usa o sistema Ă© + # operador e motorista brasileiros. devise: failure: + already_authenticated: "VocĂȘ jĂĄ estĂĄ autenticado." + inactive: "Sua conta ainda nĂŁo foi ativada." + invalid: "%{authentication_keys} ou senha invĂĄlidos." + not_found_in_database: "%{authentication_keys} ou senha invĂĄlidos." + timeout: "Sua sessĂŁo expirou. Faça login novamente para continuar." + unauthenticated: "Para continuar, faça login." + # Conta desativada pelo administrador (User#inactive_message). + # Sem esta chave, o Devise mostraria o sĂ­mbolo cru "conta_desativada". conta_desativada: "Sua conta estĂĄ desativada. Fale com um administrador." + sessions: + signed_in: "Login efetuado com sucesso." + signed_out: "Logout efetuado com sucesso." + already_signed_out: "Logout efetuado com sucesso." + passwords: + send_instructions: "Em alguns minutos vocĂȘ receberĂĄ um e-mail com instruçÔes para redefinir sua senha." + send_paranoid_instructions: "Se o seu e-mail existir em nosso cadastro, vocĂȘ receberĂĄ um link para redefinir a senha." + updated: "Sua senha foi alterada com sucesso. VocĂȘ jĂĄ estĂĄ autenticado." + updated_not_active: "Sua senha foi alterada com sucesso." + no_token: "Esta pĂĄgina sĂł pode ser acessada pelo link do e-mail de redefinição de senha. Peça um novo link." + mailer: + reset_password_instructions: + subject: "InstruçÔes para redefinir sua senha" + + # Chaves que o Devise procura na RAIZ do locale (nĂŁo dentro de `devise:`). + errors: + messages: + not_found: "nĂŁo encontrado" + already_confirmed: "jĂĄ foi confirmado, tente fazer login" + not_locked: "nĂŁo estĂĄ bloqueado" + expired: "expirou, solicite um novo" + not_saved: + one: "1 erro impediu que este %{resource} fosse salvo:" + other: "%{count} erros impediram que este %{resource} fosse salvo:" diff --git a/docker-compose.yml b/docker-compose.yml index 00c9a9c..cbfde5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,10 @@ services: # POR QUE NÃO É FIXA: o mesmo NAS roda mais de um stack, e dois containers # nĂŁo publicam a mesma porta ("driver failed programming external # connectivity ... port is already allocated"). O ambiente de TESTE usa a - # 3001; a 3000 jĂĄ estĂĄ ocupada. + # 3001; a 3000 jĂĄ estĂĄ ocupada. Por isso o PADRÃO aqui Ă© 3001 e nĂŁo 3000: + # Ă© o valor que mantĂ©m ESTE ambiente de pĂ© se o .env se perder — foi + # exatamente o que aconteceu, e o padrĂŁo 3000 fez o container morrer com + # "port is already allocated". Na produção (branch main) o padrĂŁo Ă© 3000. # # Isto vivia como alteração LOCAL no docker-compose.yml do servidor — e em # 27/08/2026 um `git reset --hard` levou junto, derrubando o ambiente com o @@ -21,7 +24,7 @@ services: # ⚠ Ao mudar, ajuste JUNTO o proxy reverso (DSM/Cloudflare) — senĂŁo o 502 # sĂł muda de lugar. ports: - - "${PORTA_APP:-3000}:3000" + - "${PORTA_APP:-3001}:3000" env_file: .env environment: DATABASE_URL: ${DATABASE_URL} @@ -37,6 +40,24 @@ services: # ajuste sem rebuild — basta TZ=... no .env. Ver deploy/ntp-seguro.sh: # a HORA em si Ă© do host, isto aqui sĂł define o fuso de leitura. TZ: ${TZ:-America/Sao_Paulo} + + # Endereço pĂșblico e nome do sistema. Ficam aqui com PADRÃO porque nĂŁo sĂŁo + # segredo — assim um .env perdido nĂŁo leva junto a configuração do + # ambiente. O .env continua mandando: se a variĂĄvel existir lĂĄ, ela vence. + # + # ⚠ APP_HOST Ă© SÓ O HOST, sem "https://". O cĂłdigo monta os links como + # "https://#{APP_HOST}/motorista" (consolidacao_mailer.rb, + # notificacao_service.rb, gatilhos.rb) — com o esquema no valor sai + # "https://https://..." e todo link de e-mail e WhatsApp quebra. + APP_HOST: ${APP_HOST:-teste.reemtransportes.com.br} + APP_NAME: "${APP_NAME:-Reem LogĂ­stica}" + # Hosts extras aceitos alĂ©m do APP_HOST (vĂ­rgula separa vĂĄrios). Com + # RAILS_ENV=production o Rails sĂł aceita os hosts listados; o acesso pela + # rede interna chega com o IP do NAS e era recusado com + # "Blocked hosts: 100.75.222.23:3001". Ver config/environments/production.rb. + HOSTS_PERMITIDOS: ${HOSTS_PERMITIDOS:-100.75.222.23} + # A ponte do WhatsApp Ă© alcançada pelo nome do serviço na rede do compose. + WHATSAPP_URL: ${WHATSAPP_URL:-http://whatsapp:3001} volumes: - ".:/app" - "bundle_cache:/usr/local/bundle" -- 2.49.1 From b3464e3acdba9274fce98268ea3b742b444b2609902e8d9c465e1945383d4cc9 Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 19:20:49 -0300 Subject: [PATCH 25/40] =?UTF-8?q?Romaneio:=20lista=20de=20ve=C3=ADculos=20?= =?UTF-8?q?vis=C3=ADvel,=20busca=20com=20contador=20e=20logo=20escolh?= =?UTF-8?q?=C3=ADvel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traz para a tela as funçÔes do programa original (Romaneiro PDF), na linha de design do projeto: - VeĂ­culos deixam de ser um - <% @veiculos.each do |v| %> - - <% end %> - - +
+
+ +
+ + <%# RĂłtulo que descreve o que o controle faz (diretriz 2): limpa a BUSCA, + nĂŁo o romaneio. %> + +
-
-
-
- - <% atual = @veiculos.find { |v| v.veiculo == @veiculo } %> - -
-
- - -
+ <%# Contador: no programa original ele fica no rodapĂ© da tabela ("0 registros") + e Ă© como se percebe que a busca escondeu tudo — sem ele, a tabela vazia + parece plano vazio. %> + <%# Sem um segundo alvo "status" aqui: ele jĂĄ existe no _tabela.html.erb + ("AlteraçÔes salvam sozinhas") e o Stimulus devolveria sĂł o primeiro. %> +

+ <%= @linhas.size %> <%= @linhas.size == 1 ? 'parada' : 'paradas' %> nesta folha +

diff --git a/app/views/admin/romaneios/_tabela.html.erb b/app/views/admin/romaneios/_tabela.html.erb index 815470d..8446ace 100644 --- a/app/views/admin/romaneios/_tabela.html.erb +++ b/app/views/admin/romaneios/_tabela.html.erb @@ -7,7 +7,10 @@ traz HTML junto. %>
-

<%= linhas.size %> paradas

+ <%# Sem repetir a contagem aqui: ela agora Ă© VIVA no bloco de filtros (muda + conforme a busca esconde linhas). Dois nĂșmeros para a mesma coisa, um + atualizando e outro nĂŁo, Ă© a divergĂȘncia que a diretriz 1 proĂ­be. %> +

Paradas de <%= @veiculo %>

AlteraçÔes salvam sozinhas
diff --git a/app/views/admin/romaneios/_veiculos.html.erb b/app/views/admin/romaneios/_veiculos.html.erb new file mode 100644 index 0000000..1af99cb --- /dev/null +++ b/app/views/admin/romaneios/_veiculos.html.erb @@ -0,0 +1,78 @@ +<%# Coluna "Veículos" — a lista INTEIRA visível, como no programa original. + Antes era um +

+ NĂŁo sai impresso — a linha do rodapĂ© Ă© assinada Ă  mĂŁo na retirada. +

+
+ +
+ <%= link_to pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo), + target: '_blank', rel: 'noopener', + class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#1a1a1a] + hover:bg-[#242424] text-white border border-white/10 rounded-xl' do %> + <%= icone :visualizar, cor: nil %> PDF deste veĂ­culo + <% end %> + <%= link_to pdf_admin_romaneio_path(@romaneio), + class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#f97316] + hover:bg-orange-500 text-white font-semibold rounded-xl' do %> + <%= icone :baixar, cor: nil %> PDF de TODOS os veĂ­culos + <% end %> +
+
diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb index 5f1b918..1ef9c8a 100644 --- a/app/views/admin/romaneios/index.html.erb +++ b/app/views/admin/romaneios/index.html.erb @@ -20,6 +20,37 @@

+ <%# Logo do cabeçalho — zona "Arquivos" do programa original. + Fica ANTES da importação porque Ă© configuração que vale para todos os + romaneios, nĂŁo escolha por plano: quem troca de contratante troca uma vez. %> +
+ <%= form_with url: atualizar_logo_admin_romaneios_path, method: :post, multipart: true, + data: { turbo: false }, + class: 'flex flex-col sm:flex-row sm:items-end gap-3' do %> +
+ +

+ Em uso: + <%= @logo_atual.presence || 'logo-gade.png (padrĂŁo)' %> +

+ +

+ PNG ou JPG, atĂ© 2 MB — Ă© o que o PDF consegue embutir. Vale para os prĂłximos PDFs gerados. +

+
+ + <% end %> +
+ <%# Importação %>

Importar plano

diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb index 5ad792e..d10aa6b 100644 --- a/app/views/admin/romaneios/show.html.erb +++ b/app/views/admin/romaneios/show.html.erb @@ -51,8 +51,16 @@ <% else %> <%= render 'avisos' %> -
-
+ <%# TrĂȘs zonas, como no programa que a operação jĂĄ usava: VEÍCULOS Ă  esquerda, + edição no meio, PDF de verdade Ă  direita. Em telas mĂ©dias a prĂ©via desce + para baixo (duas colunas); no celular vira uma coluna sĂł. %> +
+
+ <%= render 'veiculos' %> +
+ +
<%= render 'controles' %> <%= render 'tabela', linhas: @linhas %> <%= render 'removidas', linhas: @removidas %> @@ -60,8 +68,8 @@ <%# PrĂ©via — no celular vira um bloco recolhido: 6 colunas em tela pequena exigiriam zoom, e o que importa ali Ă© editar, nĂŁo conferir o layout. %> -
-
+
+
<%= icone :visualizar %> Ver prévia do PDF @@ -70,7 +78,7 @@ class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
- +
<% end %> diff --git a/bin/docker-boot b/bin/docker-boot index ebd8348..4a723d0 100755 --- a/bin/docker-boot +++ b/bin/docker-boot @@ -40,6 +40,30 @@ if ! bundle check >/dev/null 2>&1; then fi # ── 4. Migrations ──────────────────────────────────────────────────────────── +# ANTES de tentar, confere o DATABASE_URL. Este projeto NÃO tem +# config/database.yml: a conexĂŁo inteira sai dessa Ășnica variĂĄvel (as DB_HOST, +# DB_USER etc. do .env nĂŁo sĂŁo lidas por ninguĂ©m — sĂŁo sĂł documentação). +# +# POR QUE ESTA CHECAGEM EXISTE: em 27/08/2026 o .env foi recriado a partir do +# .env.example e o DATABASE_URL ficou com o valor de exemplo. O resultado foram +# 10 tentativas cuspindo 40 linhas de backtrace atĂ© uma +# `URI::InvalidURIError: ... SEU_IP` no fim — ilegĂ­vel no log do Container +# Manager. Placeholder e URL vazia nĂŁo sĂŁo "banco fora do ar": nenhuma espera +# resolve, entĂŁo falha na hora e diz o que fazer. +if [ -z "${DATABASE_URL:-}" ]; then + log "ERRO: DATABASE_URL vazia. Sem config/database.yml, Ă© ela que define o banco." + log " Formato: postgresql://USUARIO:SENHA@HOST:5432/NOME_DO_BANCO" + exit 1 +fi +case "$DATABASE_URL" in + *SEU_IP*|*senha_segura*|*SEU_HOST*) + log "ERRO: DATABASE_URL ainda estĂĄ com o valor de EXEMPLO do .env.example:" + log " $(printf '%s' "$DATABASE_URL" | sed 's|://[^@]*@|://***:***@|')" + log " Preencha com o IP, o usuĂĄrio e a senha reais do PostgreSQL." + exit 1 + ;; +esac + # O PostgreSQL Ă© EXTERNO ao compose: num reboot do servidor o Rails pode subir # antes do banco aceitar conexĂŁo. Sem a espera abaixo o container sairia com # erro e o `restart: unless-stopped` ficaria reiniciando em loop — parecendo diff --git a/bin/sondar_plano_do_dia b/bin/sondar_plano_do_dia index 877589e..4433b2a 100755 --- a/bin/sondar_plano_do_dia +++ b/bin/sondar_plano_do_dia @@ -146,4 +146,73 @@ puts ' Compare com o espelho, no console do app:' puts " Entrega.da_conta_gade.no_periodo(Date.parse('#{data}'), Date.parse('#{data}'))" \ '.distinct.count(:vehicle)' puts + +# ── 4. O plano tem NOME? ──────────────────────────────────────────────────── +# POR QUE ISTO IMPORTA: a operação NÃO trabalha por data — ela identifica o +# plano pelo NOME ("EMAD 09.2026" e afins), que Ă© inclusive o texto impresso no +# canto do romaneio. Hoje a tela pede a data porque Ă© o Ășnico filtro que +# sabemos que a API aceita (/v1/routes/visits/?planned_date=). Para trocar por +# uma BUSCA POR NOME Ă© preciso primeiro descobrir se existe endpoint de planos +# e qual campo carrega o nome — Ă© o que esta seção responde. +# +# As URLs abaixo sĂŁo CANDIDATAS: a Ășnica rota /v1/plans/ que o app jĂĄ usa Ă© +# /v1/plans/visits//detail/, entĂŁo sabemos que o namespace existe, mas nĂŁo +# se ele lista planos. 404/403 aqui Ă© resposta vĂĄlida — significa "nĂŁo dĂĄ por +# esse caminho", e a busca por nome teria que ser feita filtrando as visitas. +puts +puts '[4] procurando um endpoint de PLANOS (para buscar por nome em vez de data)' + +CHAVES_NOME = /name|title|label|descri|plan/i.freeze + +candidatas = [ + '/v1/plans/', + "/v1/plans/?planned_date=#{data}", + '/v1/routes/plans/', + "/v1/routes/plans/?planned_date=#{data}" +] + +achou_algum = false +candidatas.each do |caminho| + codigo, corpo, seg = get(caminho) + puts " GET #{caminho}" + puts " HTTP #{codigo} · #{corpo.bytesize} bytes · #{seg.round(2)}s" + + dados = json(corpo) + # A API Ă© Django REST: ora devolve lista, ora {"results": [...]}. + itens = dados.is_a?(Array) ? dados : (dados.is_a?(Hash) ? Array(dados['results']) : []) + if itens.empty? + puts " sem itens (#{corpo.slice(0, 120)})" + next + end + + achou_algum = true + item = itens.first + next unless item.is_a?(Hash) + + puts " itens: #{itens.size}" + puts " chaves: #{item.keys.sort.join(', ')}" + nomes = item.select { |k, v| k.to_s.match?(CHAVES_NOME) && !v.nil? && v.to_s.strip != '' } + if nomes.empty? + puts ' => nenhum campo de nome PREENCHIDO neste item' + else + puts ' => campos de nome preenchidos:' + nomes.each { |k, v| puts " #{k} = #{v.inspect.slice(0, 90)}" } + puts " => primeiros nomes: #{itens.filter_map { |i| i.is_a?(Hash) ? nomes.keys.filter_map { |k| i[k] }.first : nil }.first(5).inspect}" + end +end + +# Plano B: se nĂŁo hĂĄ endpoint de planos, o nome pode vir na prĂłpria visita. +unless achou_algum + puts ' nenhum endpoint de planos respondeu com itens.' + puts ' procurando um campo de nome na VISITA (plano B — filtrar visitas pelo nome):' + nomes_visita = primeira.select { |k, v| k.to_s.match?(CHAVES_NOME) && !v.nil? && v.to_s.strip != '' } + if nomes_visita.empty? + puts ' (nenhum) => busca por nome NÃO Ă© possĂ­vel pela API; a data continua sendo' + puts ' o Ășnico filtro, e o nome fica sĂł como rĂłtulo do PDF.' + else + nomes_visita.each { |k, v| puts " #{k} = #{v.inspect.slice(0, 90)}" } + end +end + +puts puts 'Anote o resultado no cabeçalho deste arquivo antes de commitar.' diff --git a/config/environments/production.rb b/config/environments/production.rb index 7eeee1a..396427b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -8,8 +8,24 @@ Rails.application.configure do # HTTPS obrigatĂłrio: redireciona HTTP→HTTPS e marca cookies como "Secure". # assume_ssl faz o Rails confiar no TLS terminado no Cloudflare (que envia # X-Forwarded-Proto=https), evitando loop de redirecionamento. - config.assume_ssl = true - config.force_ssl = true + # + # ⚠ EFEITO COLATERAL, e nĂŁo Ă© bug: com assume_ssl o Rails calcula + # `request.base_url` como https:// SEMPRE. Quem abre o sistema direto pelo + # IP em HTTP (http://100.75.222.23:3001) manda `Origin: http://...`, a + # verificação de CSRF compara os dois, nĂŁo bate, e TODO POST volta 422 + # ("A ação foi recusada por uma verificação de segurança") — enquanto os GETs + # passam e a tela parece normal. Aconteceu em 27/08/2026 ao importar o plano + # do romaneio. O uso normal Ă© pelo domĂ­nio, onde Origin e base_url sĂŁo https. + # + # O gate abaixo existe para DIAGNÓSTICO pelo IP: `FORCE_SSL=false` no .env + # devolve o acesso por HTTP puro. Sem a variĂĄvel (ou com qualquer outro + # valor) o HTTPS continua obrigatĂłrio — o padrĂŁo seguro. Antes isto estava + # fixo aqui e o FORCE_SSL do .env, respeitado no application.rb, nĂŁo tinha + # efeito nenhum em produção: as duas configuraçÔes se contradiziam. + unless ENV["FORCE_SSL"] == "false" + config.assume_ssl = true + config.force_ssl = true + end config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") config.log_tags = [:request_id] config.logger = ActiveSupport::Logger.new(STDOUT).tap { |l| l.formatter = ::Logger::Formatter.new } diff --git a/spec/services/romaneios/casador_de_operacao_spec.rb b/spec/services/romaneios/casador_de_operacao_spec.rb new file mode 100644 index 0000000..78deb3f --- /dev/null +++ b/spec/services/romaneios/casador_de_operacao_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +# Os nomes aqui sĂŁo os REAIS, lidos da tela de Planos do SimpliRoute +# (Imagens para correção/Imagem colada (11).png) e do cabeçalho do romaneio do +# programa antigo: "ENTREGAS EMAD 09.2026", "UBS SUL E LESTE AGOSTO 2026", +# "AVULSAS 3 EMAD/SUDESTE AGOSTO 2026". As trĂȘs diferenças que quebram uma +# comparação de texto — conectivo "E", mĂȘs abreviado x por extenso, e mĂȘs +# NUMÉRICO ("09.2026") — estĂŁo todas cobertas. +RSpec.describe Romaneios::CasadorDeOperacao do + # Lista fixa: o teste Ă© do CASAMENTO, nĂŁo do catĂĄlogo do banco. + let(:tabelas) do + %w[ + gade_entregas_emad_ago_2026 + gade_entregas_emad_set_2026 + gade_entregas_ubs_sul_leste_ago_2026 + gade_entregas_ubs_norte_ago_2026 + gade_entregas_ubs_oeste_ago_2026 + gade_entregas_avulsas_3_emad_sudeste_ago_2026 + gade_entregas_sudeste_julho_2026 + ] + end + + def casar(nome) = described_class.casar(nome, tabelas: tabelas) + + it 'casa o mĂȘs NUMÉRICO do plano com o mĂȘs abreviado da tabela' do + # "09" sĂł Ă© lido como mĂȘs por vir colado ao ano — ver o "3" de AVULSAS abaixo. + expect(casar('ENTREGAS EMAD 09.2026')).to eq('gade_entregas_emad_set_2026') + end + + it 'casa o mĂȘs por extenso com a abreviação da tabela' do + expect(casar('EMAD SETEMBRO 2026')).to eq('gade_entregas_emad_set_2026') + expect(casar('EMAD AGOSTO 2026')).to eq('gade_entregas_emad_ago_2026') + end + + it 'ignora o conectivo "E", que existe no plano e nĂŁo no nome da tabela' do + expect(casar('UBS SUL E LESTE AGOSTO 2026')).to eq('gade_entregas_ubs_sul_leste_ago_2026') + end + + it 'nĂŁo confunde UBS de regiĂ”es diferentes no mesmo mĂȘs' do + expect(casar('UBS NORTE AGOSTO 2026')).to eq('gade_entregas_ubs_norte_ago_2026') + expect(casar('UBS OESTE AGOSTO 2026')).to eq('gade_entregas_ubs_oeste_ago_2026') + end + + it 'trata a barra como separador e nĂŁo lĂȘ o "3" de AVULSAS como março' do + expect(casar('AVULSAS 3 EMAD/SUDESTE AGOSTO 2026')) + .to eq('gade_entregas_avulsas_3_emad_sudeste_ago_2026') + end + + it 'nĂŁo depende de maiĂșsculas nem de acento' do + expect(casar('entregas emad 09.2026')).to eq('gade_entregas_emad_set_2026') + end + + # ── O que NÃO pode casar ────────────────────────────────────────────────── + # Vincular a operação errada Ă© pior do que nĂŁo vincular: APARELHO e TELEFONE + # sairiam preenchidos com dados de OUTRO mĂȘs e ninguĂ©m desconfiaria. + it 'devolve nil quando o mĂȘs nĂŁo tem tabela' do + expect(casar('EMAD DEZEMBRO 2026')).to be_nil + end + + it 'devolve nil quando o mĂȘs bate mas a operação nĂŁo existe' do + expect(casar('CARDIOLOGIA AGOSTO 2026')).to be_nil + end + + it 'devolve nil com nome vazio' do + expect(casar('')).to be_nil + end + + it 'devolve nil (e diz o motivo) quando duas operaçÔes empatam' do + resultado = described_class.new( + 'EMAD AGOSTO 2026', + tabelas: %w[gade_entregas_emad_ago_2026 gade_entregas_emad_agosto_2026] + ).resultado + + expect(resultado.tabela).to be_nil + expect(resultado.confianca).to eq(:ambigua) + expect(resultado.motivo).to include('escolha na lista') + end +end -- 2.49.1 From ab126d13dbb4ee55c31b4ff209a4abe67494d619c40f337544711fca35fe630f Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 27 Aug 2026 19:57:33 -0300 Subject: [PATCH 27/40] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20de=20Read.me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 333 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 253 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 9036554..b006dd7 100644 --- a/README.md +++ b/README.md @@ -3072,118 +3072,291 @@ caminho do servidor e o trace inteiro — para **qualquer pessoa** que provocass ---
-🕐 RelĂłgio, restart automĂĄtico e migrations no boot +đŸ› ïž 27/08/2026 — Deploy: relĂłgio, arquivos ĂłrfĂŁos e tudo que caiu junto -## O problema relatado +Um relato sĂł, porque foi **uma corrente**: um problema descoberto expunha o +seguinte. Vale mais como mapa de diagnĂłstico do que como changelog. -> "toda vez que faço o deploy ou reinicio o Docker a data e hora ficam erradas." - -SĂŁo **dois problemas diferentes** que se confundem porque aparecem no mesmo lugar: - -| Sintoma | Causa | Onde se resolve | +| Sintoma na tela | Causa real | Onde se resolveu | |---|---|---| -| Hora **sempre 3h adiantada**, todo restart | O container subia em **UTC** | `Dockerfile` (fuso) | -| Hora **derivando / errada depois de reboot** | RelĂłgio do **host** sem sincronia | `deploy/ntp-seguro.sh` (NTS) | +| Hora sempre 3h adiantada | Container subia em **UTC** | `Dockerfile` (TZ + `/etc/localtime`) | +| Hora errada depois de reboot | RelĂłgio do **host** sem sincronia | `deploy/ntp-seguro.sh` (chrony + NTS) | +| `env file .env not found` | `.env` nĂŁo Ă© versionado e a pasta foi recriada | recriar no servidor + backup fora da ĂĄrvore | +| `Couldn't find Active Storage configuration` | `config/storage.yml` nunca esteve no git | arquivo versionado | +| `port is already allocated` | Porta 3001 vivia como alteração **local** no compose | `${PORTA_APP:-3001}` | +| `Blocked hosts: 100.75.222.23:3001` | `config.hosts` ligado sĂł com o domĂ­nio | `HOSTS_PERMITIDOS` | +| `Translation missing 
 devise.failure` | `fallbacks = true` caĂ­a no prĂłprio pt-BR | `fallbacks = [:en]` + traduçÔes | +| 422 "recusada por verificação de segurança" | `assume_ssl` fixo x acesso por IP em HTTP | gate do `FORCE_SSL` | +| `URI::InvalidURIError: 
 SEU_IP` | `DATABASE_URL` com o valor de exemplo | falha rĂĄpida no boot | -### 1. Fuso do container (o "sempre 3h Ă  frente") +## 1. RelĂłgio: fuso Ă© do container, hora Ă© do host -A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails atĂ© mostrava a hora certa -(`config.time_zone = "America/Sao_Paulo"`), mas **tudo que era do sistema -operacional continuava adiantado** — e o pior caso nĂŁo era visual: +A imagem `ruby:3.2.2-slim` sobe em UTC. O Rails mostrava a hora certa +(`config.time_zone`), mas **tudo do sistema operacional** ficava adiantado — e o +pior caso nĂŁo era visual: -> **O cron rodava no horĂĄrio errado.** `every "*/30 8-18"` no `config/schedule.rb` -> executava das **05h Ă s 15h** de BrasĂ­lia, nĂŁo das 08h Ă s 18h. +> **O cron rodava no horĂĄrio errado.** `every "*/30 8-18"` executava das **05h Ă s +> 15h** de BrasĂ­lia. -Agora o `Dockerfile` fixa `TZ=America/Sao_Paulo` **e** o symlink de -`/etc/localtime` — os dois, porque o cron do Debian lĂȘ o arquivo, nĂŁo a -variĂĄvel. O mesmo foi feito no `whatsapp/Dockerfile` (Alpine precisa do pacote -`tzdata`, que nĂŁo vem na imagem). DĂĄ para ajustar sem rebuild pelo `TZ` do `.env`. +O `Dockerfile` agora fixa `TZ=America/Sao_Paulo` **e** o symlink de +`/etc/localtime` — os dois, porque o cron do Debian lĂȘ o arquivo, nĂŁo a variĂĄvel. +Mesmo tratamento no `whatsapp/Dockerfile` (Alpine precisa do pacote `tzdata`). -### 2. Hora do host — NTP autenticado (NTS) - -**NĂŁo dĂĄ para sincronizar o relĂłgio de dentro do container**: ele lĂȘ o clock do -kernel do host. Rodar NTP lĂĄ dentro exigiria `CAP_SYS_TIME` e mudaria a hora do -servidor inteiro a partir de um processo da aplicação. - -No servidor, uma vez: +**A hora em si nĂŁo dĂĄ para sincronizar de dentro do container**: ele lĂȘ o clock do +kernel do host, e rodar NTP lĂĄ dentro exigiria `CAP_SYS_TIME`. Por isso +`deploy/ntp-seguro.sh`, que roda **uma vez no servidor** e instala **chrony com +NTS** (RFC 8915): troca de chaves por TLS (TCP 4460) e pacotes NTP assinados. +NTP comum Ă© UDP sem autenticação, e hora errada aqui muda **o dia da +consolidação, o recorte do perĂ­odo financeiro e a validade da sessĂŁo**. O script +usa 3 fontes independentes, **desliga as nĂŁo autenticadas** (deixar o `pool` +padrĂŁo anularia o ganho), desativa o `systemd-timesyncd` e liga o `rtcsync`. ```bash sudo bash deploy/ntp-seguro.sh +chronyc tracking # 'System time' na casa dos milissegundos +chronyc -N authdata # NTS ativo em cada fonte ``` -O script instala e configura o **chrony com NTS** (RFC 8915): a troca de chaves -Ă© por TLS (TCP 4460) e os pacotes NTP vĂȘm assinados, entĂŁo uma resposta forjada -no caminho Ă© descartada. NTP comum Ă© UDP sem autenticação — e hora errada aqui -nĂŁo Ă© detalhe: ela muda **o dia da consolidação, o recorte do perĂ­odo financeiro -e a validade da sessĂŁo**. +## 2. Restart automĂĄtico e migrations -O script ainda: usa **3 fontes independentes** (Cloudflare, Netnod, PTB), -**desliga as fontes nĂŁo autenticadas** (deixar o `pool` padrĂŁo anularia o ganho), -desativa o `systemd-timesyncd` (nĂŁo fala NTS e brigaria pelo relĂłgio), liga o -`rtcsync` (Ă© o relĂłgio de hardware que dĂĄ a hora no boot) e mostra a -conferĂȘncia no fim. É idempotente e guarda `.bak` da config. +`restart: unless-stopped` no serviço `app`. Escolhido em vez de `always` porque +`always` reergue o container atĂ© depois de um `stop` deliberado, tirando do +operador a chance de deixar o sistema fora do ar de propĂłsito. -Conferir depois, a qualquer momento: +O boot saiu do `command:` de uma linha e virou **`bin/docker-boot`**: -```bash -chronyc tracking # 'System time' deve ficar na casa dos milissegundos -chronyc -N authdata # NTS ativo em cada fonte (Cook > 0) +1. **relĂłgio** — imprime hora e fuso (se aparecer UTC, o rebuild nĂŁo pegou); +2. **pid ĂłrfĂŁo**; 3. **gems** (o volume `bundle_cache` sombreia os da imagem); +4. **migrations** — `db:prepare`, com **espera pelo banco**; +5. **cron** (best-effort); 6. **Puma**. + +A migration Ă© automĂĄtica a todo restart. A espera do passo 4 existe porque o +PostgreSQL Ă© externo ao compose: num reboot o Rails pode subir antes de o banco +aceitar conexĂŁo e, com `unless-stopped`, isso viraria **loop de reinĂ­cio** +parecendo erro de migration. + +**Falha rĂĄpida:** `DATABASE_URL` vazia ou com o valor de exemplo nĂŁo Ă© "banco fora +do ar" — nenhuma espera resolve. O boot para na primeira tentativa dizendo o que +preencher, **com a credencial mascarada no log**. Este projeto **nĂŁo tem +`config/database.yml`**: a conexĂŁo inteira sai dessa variĂĄvel, e ninguĂ©m lĂȘ +`DB_HOST`/`DB_NAME`/`DB_USER`/`DB_PASSWORD`. + +## 3. Arquivos que o git nĂŁo levava — e por isso sumiram juntos + +A pasta do deploy foi recriada e levou embora todo arquivo **nĂŁo versionado**. Dois +apareceram: + +- **`.env`** — estĂĄ no `.gitignore` por conter senha, e assim deve continuar. + Guarde uma cĂłpia fora da ĂĄrvore do projeto e prefira `git pull`/`reset --hard` + na prĂłpria pasta a recriĂĄ-la (arquivo ignorado sobrevive ao pull). +- **`config/storage.yml`** — nĂŁo tinha motivo para estar de fora. O app faz + `require "rails/all"`, entĂŁo o Active Storage sempre carrega e os + `config/environments/*.rb` definem `active_storage.service = :local`: com um + serviço definido, o Rails **exige** o arquivo no `initialize!` e o servidor nĂŁo + sobe. **Agora Ă© versionado.** + +## 4. Configuração do ambiente saiu do `.env` e entrou no compose + +A porta do teste (**3001**, porque a 3000 estĂĄ ocupada por outro stack) vivia como +alteração **local** no `docker-compose.yml` do servidor — e um `git reset --hard` +a levou junto, derrubando o ambiente com o proxy apontando para uma porta sem +ninguĂ©m escutando. Agora os valores **nĂŁo secretos** tĂȘm padrĂŁo no compose: + +```yaml +ports: + - "${PORTA_APP:-3001}:3000" +environment: + TZ: ${TZ:-America/Sao_Paulo} + APP_HOST: ${APP_HOST:-teste.reemtransportes.com.br} + APP_NAME: "${APP_NAME:-Reem LogĂ­stica}" + WHATSAPP_URL: ${WHATSAPP_URL:-http://whatsapp:3001} + HOSTS_PERMITIDOS: ${HOSTS_PERMITIDOS:-100.75.222.23} ``` -> **Firewall:** precisa de saĂ­da em **UDP 123** e **TCP 4460**. Sem a 4460 o NTS -> nĂŁo fecha e o chrony fica sem fonte. +`${VAR:-padrĂŁo}`: **o `.env` continua mandando**; o padrĂŁo sĂł entra quando a +variĂĄvel falta. Segredos (`DB_*`, `SECRET_KEY_BASE`, `SIMPLIROUTE_TOKEN`, +`WHATSAPP_TOKEN`, `SMTP_*`, `NOTIFICACAO_SECRET`) **nunca** vĂŁo para o yaml. -## Restart automĂĄtico + migrations +> ⚠ **`APP_HOST` Ă© sĂł o host, sem `https://`.** O cĂłdigo monta os links como +> `"https://#{APP_HOST}/motorista"` (`consolidacao_mailer.rb`, +> `notificacao_service.rb`, `gatilhos.rb`): com o esquema no valor sai +> `https://https://
` e todo link de e-mail e WhatsApp quebra. -`docker-compose.yml`, serviço `app`: **`restart: unless-stopped`**. Sobe sozinho -depois de queda do processo e de reboot do servidor, e sĂł fica parado se alguĂ©m -der `docker compose stop/down`. (`always` foi descartado: ele reergue o container -atĂ© depois de um `stop` deliberado, tirando do operador a chance de deixar o -sistema fora do ar de propĂłsito.) +## 5. Produção de verdade: o que sĂł aparece fora do modo development -O boot saiu do `command:` de uma linha sĂł e virou **`bin/docker-boot`**, com o -motivo de cada passo comentado: +O servidor passou a rodar `RAILS_ENV=production`, e trĂȘs coisas apareceram: -1. **relĂłgio** — imprime a hora e o fuso no log (se aparecer UTC, o rebuild nĂŁo pegou); -2. **pid ĂłrfĂŁo** — sem limpar, o Puma se recusa a subir depois de uma queda; -3. **gems** — `bundle check || bundle install` (o volume `bundle_cache` sombreia os gems da imagem); -4. **migrations** — `db:prepare`, **com espera pelo banco**; -5. **cron** — `whenever --update-crontab` + daemon, *best-effort*; -6. **Puma**. +- **`config.hosts`** — a linha que parecia sĂł liberar o `APP_HOST` na verdade + **liga** a verificação e recusa todo o resto. Agora entram tambĂ©m + `localhost`/`127.0.0.1` (para o `curl` de dentro do NAS servir de diagnĂłstico) e + a lista `HOSTS_PERMITIDOS`. +- **`i18n.fallbacks = true`** significa "caia no `default_locale`" — que aqui Ă© o + prĂłprio pt-BR. O fallback apontava para si mesmo e chave ausente virava + `Translation missing` **na tela do usuĂĄrio** (tela de login). Agora `[:en]`, + igual ao `application.rb`, e o `pt-BR.yml` ganhou as traduçÔes do Devise + (`failure`, `sessions`, `passwords`) — o projeto nĂŁo usa `devise-i18n`. +- **`assume_ssl`** estava fixo, entĂŁo `request.base_url` virava `https://` e o + `Origin: http://` de quem abre pelo IP nĂŁo batia: **todo POST voltava 422** + ("recusada por uma verificação de segurança") enquanto os GETs passavam e a tela + parecia normal. Foi o que quebrou a importação do plano. Um gate `FORCE_SSL=false` + devolve o acesso por HTTP para diagnĂłstico; sem a variĂĄvel, HTTPS continua + obrigatĂłrio. -Ou seja: **a migration Ă© automĂĄtica a todo restart** — `db:prepare` cria o banco -se nĂŁo existir, aplica as migrations pendentes e sĂł faz seed em banco novo -(idempotente). NĂŁo hĂĄ passo manual depois do deploy. +> **Proxy reverso:** o destino tem que ser **HTTP** na porta do container. Com +> `https://` o Puma responde +> `Are you trying to open an SSL connection to a non-SSL Puma?`. -> **Por que a espera no passo 4:** o PostgreSQL Ă© externo ao compose. Num reboot -> do servidor o Rails pode subir antes de o banco aceitar conexĂŁo — e agora, com -> `restart: unless-stopped`, isso viraria um **loop de reinĂ­cio** parecendo erro -> de migration. SĂŁo 10 tentativas × 6s. Se falhar depois disso (migration -> quebrada, credencial errada), o container sai com erro **de propĂłsito**: melhor -> do que servir a aplicação contra um schema desatualizado. +## 6. `db/schema.rb` entrou no `.gitignore` -## Deploy +Nunca foi versionado (o schema vive nas migrations). Como o compose monta +`.:/app`, o `db:prepare` do boot escreve o arquivo **no host** — e um `git add .` +no meio de um rebase o capturou e virou conflito de deploy. Em +`RAILS_ENV=production` ele nem seria gerado (`dump_schema_after_migration = false`). + +> **Deploy Ă© `git fetch` + `git reset --hard origin/`**, nĂŁo `pull --rebase`: +> o container reescreve `Gemfile.lock` e `db/schema.rb` no host, entĂŁo o checkout +> produz alteração local sozinho e todo rebase bate de frente com ela. + +## ▶ Deploy ```bash -# 1. No servidor, uma Ășnica vez — relĂłgio com NTP autenticado: -sudo bash deploy/ntp-seguro.sh - -# 2. Rebuild obrigatĂłrio (o fuso entra na imagem): -docker compose up -d --build - -# 3. Conferir no log que o container estĂĄ em -03 e nĂŁo em UTC: -docker compose logs app | grep '\[boot\] relĂłgio' +sudo bash deploy/ntp-seguro.sh # 1x no servidor +git fetch origin && git reset --hard origin/teste +docker compose up -d --build # --build: o fuso entra na imagem +docker compose logs app | grep '\[boot\]' +curl -I http://127.0.0.1:3001/ # separa "app caiu" de "proxy errado" ``` ## 📂 Arquivos ``` -Dockerfile # TZ=America/Sao_Paulo + tzdata + /etc/localtime; CMD -> bin/docker-boot -whatsapp/Dockerfile # tzdata (Alpine) + mesmo fuso -docker-compose.yml # restart: unless-stopped, TZ, command: bin/docker-boot -bin/docker-boot # boot documentado: migrations com espera, cron, Puma — NOVO -deploy/ntp-seguro.sh # chrony + NTS no host (rodar 1x, como root) — NOVO -.env.example # TZ=America/Sao_Paulo + aviso "fuso ≠ hora" +Dockerfile / whatsapp/Dockerfile # TZ + tzdata + /etc/localtime +docker-compose.yml # restart, PORTA_APP, APP_HOST/NAME, TZ, HOSTS_PERMITIDOS +bin/docker-boot # boot documentado: espera do banco, cron, Puma — NOVO +deploy/ntp-seguro.sh # chrony + NTS no host (1x, root) — NOVO +config/storage.yml # versionado — NOVO +config/environments/production.rb # hosts, i18n fallbacks, gate do FORCE_SSL +config/locales/pt-BR.yml # traduçÔes do Devise +.gitignore # db/schema.rb +.env.example # TZ, PORTA_APP, HOSTS_PERMITIDOS, APP_HOST sem esquema +``` + +
+ +--- + +
+🚚 Romaneio — a tela recuperou as funçÔes do programa antigo + +ReferĂȘncia: o **Romaneiro PDF (GADE Hospitalar)**, o programa em Python que a +operação usava. O PDF jĂĄ estava fechado; o que faltava era a **tela**. + +## VeĂ­culos: lista visĂ­vel, nĂŁo um `` com â€č â€ș, escolhido para nĂŁo virar armadilha de scroll com 72 +carros. Resolveu o scroll e criou outro problema: o operador via **um carro por +vez** e perdia a contagem de paradas de cada um — que Ă© justamente a conferĂȘncia +feita antes de imprimir. + +Agora Ă© a lista inteira, cada item com a contagem (`GADE_038 · 31`), o atual em +laranja, rolagem prĂłpria, e as setas â€č â€ș preservadas para percorrer carro a carro. +Cada veĂ­culo Ă© um **link de verdade** (`?veiculo=GADE_038`): volta com o botĂŁo +"voltar" do navegador e sobrevive a um F5. No celular vira faixa horizontal — +72 itens empilhados empurrariam a tabela para fora da primeira tela. + +Na mesma coluna, como no original: **motorista do veĂ­culo**, **PDF deste veĂ­culo** +e **PDF de TODOS os veĂ­culos**. + +## PrĂ©via sob demanda, em sobreposição + +Antes o ` +
+ diff --git a/app/views/admin/romaneios/_veiculos.html.erb b/app/views/admin/romaneios/_veiculos.html.erb index 50bee13..c454191 100644 --- a/app/views/admin/romaneios/_veiculos.html.erb +++ b/app/views/admin/romaneios/_veiculos.html.erb @@ -63,12 +63,10 @@
- <%# PrĂ©via sob demanda: o PDF sĂł Ă© gerado quando alguĂ©m pede para ver. %> - + <%# A prĂ©via NÃO tem botĂŁo aqui: ela Ă© o bloco recolhĂ­vel no topo da coluna + ao lado (`_previa`), aberto pelo prĂłprio navegador. Um segundo botĂŁo que + dependesse de JavaScript para abrir aquele bloco voltaria a ficar morto + quando os assets falhassem — que Ă© justamente o que se estĂĄ evitando. %> <%= link_to pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo), target: '_blank', rel: 'noopener', class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#1a1a1a] diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb index d9042dc..b2d0b60 100644 --- a/app/views/admin/romaneios/index.html.erb +++ b/app/views/admin/romaneios/index.html.erb @@ -55,7 +55,19 @@

Importar plano

+ <%# UM formulĂĄrio com DUAS actions: "Buscar no SimpliRoute" usa a action do +
e "Enviar planilha" a troca por `formaction`, para nĂŁo repetir data, + operação e rĂłtulo em dois formulĂĄrios que sairiam de sincronia. + + `authenticity_token: form_authenticity_token` Ă© o que faz o segundo botĂŁo + funcionar. Com `load_defaults 7.1` o Rails liga `per_form_csrf_tokens`: o + token embutido vale sĂł para o par (action, method) do , entĂŁo o POST + que o `formaction` manda para /importar_planilha era recusado SEMPRE, com a + tela "A ação foi recusada por uma verificação de segurança" e volta ao + inĂ­cio — enquanto o botĂŁo de buscar, na action original, passava. O helper + sem argumentos devolve o token GLOBAL, vĂĄlido para as duas actions. %> <%= form_with url: admin_romaneios_path, method: :post, multipart: true, + authenticity_token: form_authenticity_token, data: { turbo: false }, class: 'space-y-4' do %>
diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb index d2b9bae..e33410c 100644 --- a/app/views/admin/romaneios/show.html.erb +++ b/app/views/admin/romaneios/show.html.erb @@ -1,12 +1,7 @@ <%# app/views/admin/romaneios/show.html.erb Editor do romaneio: veículos à esquerda, tabela editåvel à direita, e o PDF - REAL numa sobreposição aberta sob demanda. - - A prévia é um -
-
<% end %>
diff --git a/docs/BATERIA-DE-TESTES.md b/docs/BATERIA-DE-TESTES.md index 5a94281..f37eeb1 100644 --- a/docs/BATERIA-DE-TESTES.md +++ b/docs/BATERIA-DE-TESTES.md @@ -115,29 +115,33 @@ agent-browser wait --load networkidle Na tela do romaneio: +A prévia é um `
` nativo — abre e fecha **sem JavaScript**, de propĂłsito +(quando os assets caem, ela precisa continuar funcionando): + ```bash -# 4a. O modal de prĂ©via NÃO pode estar visĂ­vel no carregamento: -agent-browser eval "const o=document.querySelector('[data-romaneio-target=overlay]'); o?getComputedStyle(o).display:'sem overlay'" -# esperado: "none" +# 4a. Fechada ao carregar, e o PDF ainda NÃO foi buscado (iframe Ă© loading=lazy): +agent-browser eval "(()=>{const d=document.querySelector('details[data-romaneio-target=overlay]'); return d ? 'aberto:'+d.open : 'PREVIA AUSENTE';})()" +# esperado: "aberto:false" -# 4b. Abrir a prĂ©via: -agent-browser snapshot -i | grep -i "prĂ©via" # achar o botĂŁo "Ver prĂ©via do PDF" -agent-browser click @eY -agent-browser eval "const o=document.querySelector('[data-romaneio-target=overlay]'); getComputedStyle(o).display + ' src:' + (document.querySelector('iframe[data-romaneio-target=preview]')?.src||'')" -# esperado: "flex src:https://.../pdf?veiculo=..." +# 4b. Abrir e conferir que o iframe aponta para o PDF: +agent-browser eval "document.querySelector('details[data-romaneio-target=overlay] summary').click()" +agent-browser eval "(()=>{const d=document.querySelector('details[data-romaneio-target=overlay]'); const f=d.querySelector('iframe'); return 'aberto:'+d.open+' src:'+(f?.getAttribute('src')||'sem src');})()" +# esperado: "aberto:true src:/admin/romaneios//pdf?veiculo=..." -# 4c. Fechar com Esc: -agent-browser press Escape -agent-browser eval "getComputedStyle(document.querySelector('[data-romaneio-target=overlay]')).display" -# esperado: "none" +# 4c. Fechar de novo: +agent-browser eval "document.querySelector('details[data-romaneio-target=overlay] summary').click()" -# 4d. PDF responde: -agent-browser eval "fetch(document.querySelector('a[href*=pdf]').href).then(r=>r.status)" -# esperado: 200 +# 4d. O PDF responde e Ă© PDF de verdade (nĂŁo a pĂĄgina de login): +agent-browser eval "(async()=>{const r=await fetch(document.querySelector('details[data-romaneio-target=overlay] iframe').src); const b=await r.blob(); return r.status+' '+b.type+' '+Math.round(b.size/1024)+'KB';})()" +# esperado: "200 application/pdf KB" ``` -**PASSOU se**: 4a="none", 4b="flex" com src preenchido, 4c="none", 4d=200, -Teste 0 limpo. **NÃO** clicar em "Reimportar plano" (tem confirmação). +**PASSOU se**: 4a fechada, 4b abre com src do PDF, 4c fecha, 4d devolve +`application/pdf`, Teste 0 limpo. **NÃO** clicar em "Reimportar plano" (tem +confirmação). + +⚠ Se 4d devolver `text/html`, a sessĂŁo caiu no meio do teste — refaça o login e +repita, **nĂŁo** registre como falha do PDF (jĂĄ deu falso positivo assim). ### Caso 4e — VĂ­nculo manual da operação (sĂł leitura) @@ -210,6 +214,47 @@ agent-browser open https://teste.reemtransportes.com.br/auth/login **PASSOU se**: cai no dashboard do motorista, valores legĂ­veis sem zoom, Teste 0 limpo. Ao final: `agent-browser set viewport 1280 800`. +## Caso 9 — Tempo de resposta (performance) + +Mede o tempo real de cada tela e do PDF. Rode logado, com a rede parada (sem +outra aba baixando coisa), e compare com a linha de base. + +```bash +# Tempo das telas (TTFB | resposta completa | DOM pronto) +for u in /dashboard /dashboard/operacoes "/consolidacoes?visao=lista" /admin/romaneios /admin/romaneios/13; do + agent-browser open "https://teste.reemtransportes.com.br$u" >/dev/null + agent-browser eval "(()=>{const n=performance.getEntriesByType('navigation')[0]; return Math.round(n.responseStart-n.requestStart)+'|'+Math.round(n.responseEnd-n.requestStart)+'|'+Math.round(n.domContentLoadedEventEnd-n.startTime);})()" +done +``` + +```bash +# Tempo do PDF (o mais pesado do sistema). Com cache-buster, senĂŁo mede o cache. +agent-browser eval "(async()=>{const t0=performance.now(); const r=await fetch('/admin/romaneios/13/pdf?veiculo=GADE_002&cb='+Math.random()); const b=await r.blob(); return Math.round(performance.now()-t0)+'ms, '+Math.round(b.size/1024)+' KB';})()" +``` + +**Linha de base medida em 28/08/2026** (romaneio de 2.047 paradas / 72 veĂ­culos, +ambiente de teste, acesso pelo domĂ­nio com Cloudflare na frente): + +| Alvo | TTFB | Resposta | DOM pronto | +|---|---|---|---| +| `/dashboard` | 339 ms | 340 ms | 493 ms | +| `/dashboard/operacoes` | 168 ms | 251 ms | 402 ms | +| `/consolidacoes?visao=lista` | 349 ms | 351 ms | 430 ms | +| `/admin/romaneios` | 49 ms | 50 ms | 127 ms | +| `/admin/romaneios/13` | 71 ms | 78 ms | 155 ms | +| **PDF de 1 veĂ­culo** | — | **228 ms** (84 KB) | — | +| **PDF de TODOS (72)** | — | **8.947 ms** (1,7 MB) | — | + +**Orçamento — FALHOU se passar de**: 1 s de TTFB em qualquer tela; 1 s no PDF de +um veĂ­culo (Ă© ele que a prĂ©via carrega, tem que abrir na hora); 30 s no PDF +completo (acima disso o proxy corta a conexĂŁo e o operador recebe erro). + +⚠ **Ponto de atenção conhecido**: o PDF de todos os veĂ­culos leva ~9 s e segura +um processo do servidor esse tempo todo. Ainda dentro do aceitĂĄvel para uma ação +manual e pontual, mas Ă© o primeiro candidato a virar download em segundo plano se +o volume crescer ou se dois operadores baixarem ao mesmo tempo. **NĂŁo** compare +esse nĂșmero com o do PDF de um veĂ­culo: sĂŁo unidades diferentes (1 folha × 72). + ## RelatĂłrio final (obrigatĂłrio) Terminar a execução com uma tabela e a lista de falhas: @@ -225,6 +270,7 @@ Terminar a execução com uma tabela e a lista de falhas: | 6 Admin | 
 | 
 | | 7 NotificaçÔes | 
 | 
 | | 8 PIN motorista | 
 | 
 | +| 9 Performance | 
 | tabela de tempos | Depois da tabela, listar **somente as falhas**, cada uma com: URL, o que era esperado, o que aconteceu (mensagem de erro/console) e o caminho do screenshot. -- 2.49.1 From 1143ed373b51ab9ce27ef9c04be9c5a5478f2a352479d19224d18b445dbf2f15 Mon Sep 17 00:00:00 2001 From: victor Date: Fri, 28 Aug 2026 12:13:42 -0300 Subject: [PATCH 33/40] =?UTF-8?q?,=20corre=C3=A7=C3=A3o=20de=20UX=20para?= =?UTF-8?q?=20gerara=20pr=C3=A9via=20do=20=20romaneio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/tailwind/application.css | 32 +++++ .../controllers/romaneio_controller.js | 27 ++-- app/views/admin/romaneios/_previa.html.erb | 127 +++++++++++++----- app/views/admin/romaneios/_veiculos.html.erb | 49 ++++--- app/views/admin/romaneios/show.html.erb | 17 ++- bin/docker-boot | 9 ++ 6 files changed, 191 insertions(+), 70 deletions(-) create mode 100644 app/assets/tailwind/application.css diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000..d80dac9 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,32 @@ +/* app/assets/tailwind/application.css + * + * ESTE ARQUIVO EXISTE PARA O DEPLOY NÃO QUEBRAR. Leia antes de apagar. + * + * A gem `tailwindcss-rails` estĂĄ no Gemfile e pendura a tarefa `tailwindcss:build` + * dentro de `assets:precompile`. Ela procura ESTE caminho fixo e, se nĂŁo achar, + * aborta com: + * + * Specified input file `./app/assets/tailwind/application.css` does not exist. + * Tasks: TOP => assets:precompile => tailwindcss:build + * + * E o `assets:precompile` morre INTEIRO nessa linha — nenhum arquivo chega em + * public/assets. O efeito na tela Ă© traiçoeiro: o Propshaft, sem manifesto, cai + * no resolvedor dinĂąmico e continua montando URLs com o digest certo, entĂŁo a + * pĂĄgina abre normal e CADA /assets/*.js devolve 404 em silĂȘncio. O sistema fica + * sem JavaScript nenhum: prĂ©via do PDF, edição inline da tabela, busca e + * carrossel todos mortos, sem erro visĂ­vel. Foi o que aconteceu de 27 a + * 28/08/2026 e custou trĂȘs rodadas de diagnĂłstico. + * + * POR QUE ESTÁ VAZIO: o CSS do sistema hoje vem do CDN do Tailwind, carregado em + * app/views/layouts/application.html.erb. Sem `@import "tailwindcss"` aqui, o + * build termina na hora e gera um arquivo vazio — nada Ă© servido a mais e o + * deploy nĂŁo perde tempo montando um CSS que ninguĂ©m carrega. + * + * DÍVIDA CONHECIDA (uma das duas, quando houver espaço): + * a) migrar do CDN para o Tailwind local — o prĂłprio Tailwind desaconselha o + * CDN em produção, e ele deixa o sistema dependendo de rede externa. Neste + * caso, troque este comentĂĄrio por `@import "tailwindcss";` e o + *