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.
53 lines
1.9 KiB
Ruby
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
|