Fila de emissão prioriza filiações vencendo no mês corrente

TLDR: O scope Membership.with_profile_approved_card_processing (aba “Em Emissão” do admin) passa a colocar no topo da fila as filiações que vencem dentro do mês corrente; as demais continuam ordenadas por data de aprovação, como hoje.

Contexto

A fila de emissão de carteiras é a aba “Em Emissão” do admin (https://admin.citrg.com/admin?scope=em_emissao), alimentada pelo scope Membership.with_profile_approved_card_processing (app/models/membership.rb:95):

ruby scope :with_profile_approved_card_processing, -> { paid.card_in_processing.with_profile_approved.valid_gte_today .reorder("user_profiles.admin_approved_at ASC NULLS LAST") }

Existe uma regra de negócio para essa fila que nunca foi implementada: quem vence no mês corrente vai para o topo, independente da data de aprovação. A regra existe porque há casos em que a pessoa só consegue ter a documentação aprovada no mês em que a filiação está vencendo. Como o scope filtra por valid_gte_today, na virada do mês a filiação vencida sai automaticamente da lista — então é preciso destacá-la enquanto ainda está no período de vigência. Se a aprovação acontece no último dia do mês, aquela carteira ainda deve ser emitida; colocar esses casos no topo ajuda o emissor a identificá-los e concluí-los antes da virada.

Hoje a fila ordena só por user_profiles.admin_approved_at ASC, então uma filiação aprovada ontem e vencendo em três dias fica no fim da lista, atrás de dezenas de aprovações antigas com vencimento distante.

Só liberar a ordenação por clique nas colunas do ActiveAdmin não resolve: a ordenação por header é de coluna única e substitui o reorder inteiro (perdendo o desempate por data de aprovação), ordenar por valid_until ASC transforma a lista inteira numa fila por vencimento em vez de um bloco prioritário no topo, e o critério passa a depender do emissor lembrar de clicar a cada acesso. Prioridade de emissão é regra de negócio e deve viver no scope.

Uma versão anterior desta regra foi escrita em 20260903172011_fix_approval_target_and_emission_queue_order.md, junto com outras duas mudanças no mesmo scope (remover valid_gte_today e trocar o desempate para memberships.admin_approved_at). Aquela spec nunca chegou a ser implementada na parte de fila — o PR que saiu dela (f4bd837, #223) mexeu só em User#membership_pending_approval e foi revertido (1eec152, #225). Esta spec substitui a parte de ordenação da fila daquela spec, reduzida ao mínimo: só a priorização.

Objetivos

  • Membership.with_profile_approved_card_processing coloca no topo da fila as filiações cujo valid_until cai dentro do mês corrente.
  • Dentro e fora do grupo prioritário, o desempate continua sendo user_profiles.admin_approved_at ASC NULLS LAST, exatamente como hoje.
  • O limite do mês é calculado no fuso da aplicação (Brasilia), não no fuso do banco.
  • A regra fica documentada em .project/docs/rules/membership/.

Fora de escopo

  • Coluna ou badge “vence este mês” no index do admin. Decisão explícita do usuário: manter a mudança mínima, só a ordenação. O emissor vê a lista já na ordem certa; nada muda visualmente.
  • Liberar ordenação por clique nos headers no scope em_emissao. O set_explicit_order (app/admin/memberships.rb:11) continua zerando params[:order] nesse scope, como hoje.
  • Membership.with_profile_approved_not_issued (app/models/membership.rb:94), usado pelo scope “Pagas e não emitidas”: tem o mesmo reorder, mas fica fora desta mudança por decisão do usuário — só “Em Emissão” recebe a prioridade agora.
  • Remover valid_gte_today do scope. Filiação vencida presa em processing continua saindo da fila na virada do mês, como hoje. A priorização reduz a janela de erro, não a elimina; tratar o represamento é outra discussão (tolerância de N dias após o vencimento, ou alerta).
  • Trocar o desempate de user_profiles.admin_approved_at para memberships.admin_approved_at. A spec anterior argumentava que a data do perfil é a errada (é única por usuário e zera a cada recusa de documento). Pode estar certo, mas é mudança de comportamento em cima de dados existentes e não foi pedida aqui — fica para uma spec própria.

Mudanças

app/models/membership.rb

ruby scope :with_profile_approved_card_processing, -> { paid.card_in_processing.with_profile_approved.valid_gte_today .reorder(Arel.sql(Membership.sanitize_sql_array([ "CASE WHEN memberships.valid_until <= ? THEN 0 ELSE 1 END ASC, " \ "user_profiles.admin_approved_at ASC NULLS LAST", Date.current.end_of_month ]))) }

Pontos de implementação:

  • O CASE vira a chave primária de ordenação e o critério atual (user_profiles.admin_approved_at ASC NULLS LAST) vira o desempate. O grupo 0 (vence neste mês) sobe inteiro para o topo; dentro dele, e dentro do grupo 1, a ordem por data de aprovação é a mesma de hoje.
  • Não precisa de limite inferior no CASE — valid_gte_today já removeu tudo que venceu antes de hoje, então valid_until <= fim do mês é exatamente “vence dentro do mês corrente”.
  • Date.current.end_of_month calculado em Ruby, não current_date no Postgres. A aplicação roda em Brasilia (config/application.rb:30) e o banco em UTC: no último dia do mês, das 21h em diante, current_date no Postgres já é o dia 1º do mês seguinte, e o grupo prioritário passaria a apontar para o mês errado justamente nas horas em que a regra mais importa.
  • Lambda, não constante: a expressão é montada a cada chamada do scope, então a virada do mês esvazia o grupo prioritário sozinha, sem restart nem cache.
  • Arel.sql(...) é necessário porque a expressão não é referência simples de coluna; sanitize_sql_array monta a string com o bind já interpolado e escapado. Não há entrada de usuário aqui, mas evita literal de data solto no SQL.

Nenhuma mudança em app/admin/memberships.rb.

Como verificar

bash docker compose run --rm -e DATABASE_HOST=database -e RAILS_ENV=test runner bin/rails test test/models/membership_test.rb make container.server.lint

make test test=<path> não roda nada neste repo (casa com uma regra vazia e sai 0). O alvo real é make run.test path=<path>, que depende de DATABASE_HOST=database no .env.

Casos novos a cobrir em test/models/membership_test.rb (mesmo padrão dos testes de scope já existentes em test/models/membership_test.rb:340, Membership.create! direto, Triple-A):

Cenário Esperado
X vence no mês corrente e foi aprovada hoje; Y vence daqui a 6 meses e foi aprovada há 3 meses X vem antes de Y
Duas filiações vencendo no mês corrente, aprovadas em datas diferentes Entre si, ordenadas por admin_approved_at crescente
Duas filiações vencendo em meses futuros, aprovadas em datas diferentes Entre si, ordenadas por admin_approved_at crescente (comportamento atual preservado)
Filiação vencendo no último dia do mês corrente, aprovada hoje Está no grupo prioritário (limite superior inclusivo)
Filiação vencendo no dia 1º do mês seguinte Não está no grupo prioritário

O caso do dia 1º do mês seguinte precisa de travel_to para uma data de referência fixa (ex.: Time.zone.local(2026, 9, 20)), senão o teste fica dependente do dia em que roda.

Cenário manual (console):

ruby Membership.with_profile_approved_card_processing .limit(15) .map { |m| [m.register_number_pad, m.valid_until, m.user.user_profile.admin_approved_at] }

As primeiras linhas devem ser todas com valid_until dentro do mês corrente.

Documentação

  • Criar .project/docs/rules/membership/emission_queue_prioritizes_current_month.md (R-010): quem vence no mês corrente vai para o topo da fila de emissão; as demais seguem a ordem de aprovação. Registrar o porquê (a filiação sai da fila na virada do mês por valid_gte_today, então precisa ser emitida enquanto ainda vigente).
  • Registrar R-010 em .project/docs/RULES.md e no índice .project/docs/README.md.