iachat/enterprise/app/jobs/captain/retention/recalculate_contact_stats_job.rb
Rodribm10 f6488ce2de feat(retention): foundation for customer retention metrics
Lays the data + job foundation for tracking customer interactions,
recurrence, and Pix conversion on Contact. Design decisions negotiated
with Rodrigo (see docs to come):

Rules:
- Gap of 30h from last message defines separate interactions
- Qualified interaction = >=2 customer msgs + >=2 attendant msgs,
  both with textual content (>= 2 letters)
- One-shot consultation = >=1+1 but below the qualified threshold
  (tracked as secondary KPI)
- Excludes contacts labeled `equipe_interna`
- is_recurring = interactions_count >= 2
- pix_generated_count counts all PixCharges; reservations_paid_count
  only counts those with status = paid

Surface area:
- Migration adds denormalized stats to contacts + indexes for fast filtering
- Captain::ContactStats::InteractionCalculatorService computes the stats
  for a single contact (pure, no persistence)
- Captain::Retention::RecalculateContactStatsJob persists them for one
  contact (idempotent)
- Captain::Retention::RecalculateAllContactStatsJob runs daily at 3am BRT,
  enqueues per-contact jobs for everyone active in the last 120 days
- Event-driven refresh: CaptainListener#conversation_resolved enqueues
  recalc; Captain::PixCharge after_create/after_update enqueues recalc
  on status change

No UI yet — that's the next layer.
2026-04-22 09:50:23 -03:00

53 lines
1.9 KiB
Ruby

# Recalcula os stats desnormalizados de retenção em um único contato.
# Enfileirado por:
# - RecalculateAllContactStatsJob (scheduler diário — processa todo contato ativo)
# - Hooks event-driven: Conversation resolvida, PixCharge atualizado (paid/expired)
#
# Idempotente: pode rodar quantas vezes quiser sem efeito colateral indevido.
class Captain::Retention::RecalculateContactStatsJob < ApplicationJob
queue_as :low
def perform(contact_id)
contact = Contact.find_by(id: contact_id)
return if contact.nil?
interaction_stats = Captain::ContactStats::InteractionCalculatorService.new(contact: contact).call
pix_stats = calculate_pix_stats(contact)
days_since = days_since_last(interaction_stats[:last_interaction_at])
# rubocop:disable Rails/SkipsModelValidations
contact.update_columns(
interactions_count: interaction_stats[:interactions_count],
one_shot_consultations_count: interaction_stats[:one_shot_consultations_count],
first_interaction_at: interaction_stats[:first_interaction_at],
last_interaction_at: interaction_stats[:last_interaction_at],
is_recurring: interaction_stats[:is_recurring],
days_since_last_interaction: days_since,
pix_generated_count: pix_stats[:pix_generated_count],
reservations_paid_count: pix_stats[:reservations_paid_count]
)
# rubocop:enable Rails/SkipsModelValidations
end
private
# pix_generated_count: número de PixCharge emitidas (independente de status)
# reservations_paid_count: PixCharges com status 'paid' (reserva real)
def calculate_pix_stats(contact)
base = Captain::PixCharge
.joins(:reservation)
.where(captain_reservations: { contact_id: contact.id })
{
pix_generated_count: base.count,
reservations_paid_count: base.where(status: 'paid').count
}
end
def days_since_last(last_at)
return nil if last_at.blank?
((Time.current - last_at) / 1.day).floor
end
end