feat(retention): summary KPIs + cohort endpoints
Exposes two JSON endpoints under /api/v1/accounts/:id/captain/reports: - GET /retention — aggregate KPIs (active/recurring/sleeping/at-risk/ churned, new vs returned in period, Pix generated/paid/conversion, retention rates at 30d and 90d) - GET /retention/cohort — monthly cohort matrix, 12 months lookback, 12 months of offset. Each cell is % of the cohort that interacted in month M+N. SQL-aggregated with DATE_TRUNC + DISTINCT so it is a single query even on large histories.
This commit is contained in:
parent
f6488ce2de
commit
aed6d62640
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Endpoints de retenção e recorrência pra Relatórios > Retenção.
|
||||
# GET /api/v1/accounts/:account_id/captain/reports/retention → summary (KPIs)
|
||||
# GET /api/v1/accounts/:account_id/captain/reports/retention/cohort → matriz cohort
|
||||
class Api::V1::Accounts::Captain::Reports::RetentionController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
def show
|
||||
summary = Captain::Reports::RetentionSummaryService.new(
|
||||
account: current_account,
|
||||
period_start: params[:start_date],
|
||||
period_end: params[:end_date]
|
||||
).call
|
||||
|
||||
render json: summary
|
||||
end
|
||||
|
||||
def cohort
|
||||
months = params[:history_months].presence&.to_i
|
||||
data = Captain::Reports::RetentionCohortService.new(
|
||||
account: current_account,
|
||||
history_months: months || Captain::Reports::RetentionCohortService::DEFAULT_HISTORY_MONTHS
|
||||
).call
|
||||
|
||||
render json: data
|
||||
end
|
||||
end
|
||||
@ -113,6 +113,9 @@ Rails.application.routes.draw do
|
||||
post :generate, on: :collection
|
||||
end
|
||||
resource :funnel, only: [:show], controller: :funnel
|
||||
resource :retention, only: [:show], controller: :retention do
|
||||
get :cohort
|
||||
end
|
||||
end
|
||||
# Roleta da Sorte - tela de resgate na recepção + relatório semanal
|
||||
resource :roleta, only: [], controller: 'roleta' do
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
# Gera a matriz de cohort mensal de retenção por interação.
|
||||
#
|
||||
# Linha = cohort (mês em que o contato teve a primeira interação).
|
||||
# Coluna = offset em meses (M+0, M+1, M+2, ..., M+12).
|
||||
# Célula = % de contatos daquela cohort que tiveram QUALQUER mensagem
|
||||
# no mês M+N.
|
||||
#
|
||||
# Granularidade mensal dispensa o gap de 30h do InteractionCalculator:
|
||||
# se o contato teve qualquer msg em um mês, conta como retido naquele mês.
|
||||
# A imprecisão de dedup por hora fica embutida dentro do bucket mensal.
|
||||
#
|
||||
# O cálculo é uma agregação SQL só, sem varrer contato a contato.
|
||||
class Captain::Reports::RetentionCohortService
|
||||
DEFAULT_HISTORY_MONTHS = 12
|
||||
MAX_OFFSET_MONTHS = 12
|
||||
|
||||
def initialize(account:, history_months: DEFAULT_HISTORY_MONTHS)
|
||||
@account = account
|
||||
@history_months = history_months.to_i.clamp(1, 24)
|
||||
end
|
||||
|
||||
def call
|
||||
sizes = cohort_sizes
|
||||
activity = cohort_activity(sizes.keys)
|
||||
|
||||
{
|
||||
generated_at: Time.current.iso8601,
|
||||
history_months: @history_months,
|
||||
max_offset_months: MAX_OFFSET_MONTHS,
|
||||
cohorts: build_cohort_rows(sizes, activity)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_cohort_rows(sizes, activity)
|
||||
sizes.sort.reverse.map do |cohort_month, size|
|
||||
{
|
||||
cohort: cohort_month.iso8601,
|
||||
size: size,
|
||||
retention: build_retention_row(cohort_month, size, activity)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_retention_row(cohort_month, size, activity)
|
||||
(0..MAX_OFFSET_MONTHS).map do |offset|
|
||||
active = activity.dig(cohort_month, cohort_month >> offset) || 0
|
||||
rate = size.zero? ? 0.0 : (active.to_f / size).round(4)
|
||||
{ month_offset: offset, count: active, rate: rate }
|
||||
end
|
||||
end
|
||||
|
||||
# Retorna { Date => Integer }: chave é o 1º dia do mês da cohort, valor é
|
||||
# a quantidade de contatos na cohort.
|
||||
def cohort_sizes
|
||||
start_month = (Time.current - @history_months.months).beginning_of_month
|
||||
|
||||
rows = @account.contacts
|
||||
.where.not(first_interaction_at: nil)
|
||||
.where('first_interaction_at >= ?', start_month)
|
||||
.group(Arel.sql("DATE_TRUNC('month', first_interaction_at)"))
|
||||
.count
|
||||
|
||||
rows.transform_keys { |ts| ts.to_date.beginning_of_month }
|
||||
end
|
||||
|
||||
# Retorna { cohort_month => { activity_month => count } }
|
||||
# Monta só os cohorts passados e considera atividade até hoje.
|
||||
def cohort_activity(cohort_months)
|
||||
return {} if cohort_months.empty?
|
||||
|
||||
start_month = cohort_months.min
|
||||
end_month = Time.current.beginning_of_month
|
||||
|
||||
sql = activity_sql(start_month, end_month)
|
||||
rows = ActiveRecord::Base.connection.exec_query(sql, 'RetentionCohort', [@account.id])
|
||||
|
||||
hash = Hash.new { |h, k| h[k] = {} }
|
||||
|
||||
rows.each do |row|
|
||||
cohort_month = row['cohort_month'].to_date.beginning_of_month
|
||||
activity_month = row['activity_month'].to_date.beginning_of_month
|
||||
hash[cohort_month][activity_month] = row['active_contacts'].to_i
|
||||
end
|
||||
|
||||
hash
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def activity_sql(start_month, end_month)
|
||||
<<~SQL.squish
|
||||
WITH contact_cohorts AS (
|
||||
SELECT id AS contact_id,
|
||||
DATE_TRUNC('month', first_interaction_at) AS cohort_month
|
||||
FROM contacts
|
||||
WHERE first_interaction_at IS NOT NULL
|
||||
AND first_interaction_at >= '#{start_month.iso8601}'
|
||||
AND account_id = $1
|
||||
),
|
||||
contact_activity AS (
|
||||
SELECT DISTINCT
|
||||
conversations.contact_id,
|
||||
DATE_TRUNC('month', messages.created_at) AS activity_month
|
||||
FROM messages
|
||||
INNER JOIN conversations ON conversations.id = messages.conversation_id
|
||||
WHERE messages.sender_type = 'Contact'
|
||||
AND messages.private = false
|
||||
AND (messages.status IS NULL OR messages.status <> #{failed_status_value})
|
||||
AND conversations.account_id = $1
|
||||
AND messages.created_at >= '#{start_month.iso8601}'
|
||||
AND messages.created_at < '#{(end_month + 1.month).iso8601}'
|
||||
)
|
||||
SELECT c.cohort_month,
|
||||
a.activity_month,
|
||||
COUNT(DISTINCT c.contact_id) AS active_contacts
|
||||
FROM contact_cohorts c
|
||||
INNER JOIN contact_activity a ON a.contact_id = c.contact_id
|
||||
WHERE a.activity_month >= c.cohort_month
|
||||
GROUP BY c.cohort_month, a.activity_month
|
||||
ORDER BY c.cohort_month, a.activity_month
|
||||
SQL
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
# Message.statuses['failed'] retorna o integer do enum.
|
||||
def failed_status_value
|
||||
Message.statuses['failed']
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,128 @@
|
||||
# Calcula KPIs agregados de retenção e recorrência para uma account.
|
||||
# Funciona sobre as colunas desnormalizadas em `contacts` mantidas por
|
||||
# Captain::Retention::RecalculateContactStatsJob — stats a partir das colunas
|
||||
# são O(1) por KPI (count + where), sem precisar varrer mensagens.
|
||||
#
|
||||
# Janela temporal do período: [period_start, period_end]. Default: mês corrente.
|
||||
# Cortes de status absolutos (ativo/dormindo/etc) sempre relativos a Time.current,
|
||||
# independentemente do período — o período afeta apenas as métricas de fluxo
|
||||
# ("novos no período", "retorno no período").
|
||||
class Captain::Reports::RetentionSummaryService
|
||||
SLEEPING_WINDOW = (30.days)..(90.days)
|
||||
AT_RISK_WINDOW = (90.days)..(180.days)
|
||||
RETENTION_30D_WINDOW = 30.days
|
||||
RETENTION_90D_WINDOW = 90.days
|
||||
|
||||
def initialize(account:, period_start: nil, period_end: nil)
|
||||
@account = account
|
||||
@period_start = period_start&.to_date || Time.current.beginning_of_month.to_date
|
||||
@period_end = period_end&.to_date || Time.current.to_date
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
period: {
|
||||
start: @period_start.iso8601,
|
||||
end: @period_end.iso8601
|
||||
},
|
||||
status: status_counts,
|
||||
flow: flow_counts,
|
||||
pix: pix_counts,
|
||||
retention: retention_rates
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contacts
|
||||
@contacts ||= @account.contacts
|
||||
end
|
||||
|
||||
# Cortes de status pelo estado atual (Time.current)
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def status_counts
|
||||
now = Time.current
|
||||
|
||||
active = contacts.where('last_interaction_at >= ?', now - 30.days).count
|
||||
recurring = contacts.where(is_recurring: true)
|
||||
.where('last_interaction_at >= ?', now - 90.days).count
|
||||
sleeping = contacts.where(last_interaction_at: (now - SLEEPING_WINDOW.max)..(now - SLEEPING_WINDOW.min)).count
|
||||
at_risk = contacts.where(last_interaction_at: (now - AT_RISK_WINDOW.max)..(now - AT_RISK_WINDOW.min)).count
|
||||
churned = contacts.where('last_interaction_at < ?', now - 180.days).count
|
||||
never_interacted = contacts.where(last_interaction_at: nil).count
|
||||
|
||||
{
|
||||
active: active,
|
||||
recurring: recurring,
|
||||
sleeping: sleeping,
|
||||
at_risk: at_risk,
|
||||
churned: churned,
|
||||
never_interacted: never_interacted
|
||||
}
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
# Fluxo dentro do período
|
||||
def flow_counts
|
||||
period_range = @period_start.beginning_of_day..@period_end.end_of_day
|
||||
|
||||
new_in_period = contacts.where(first_interaction_at: period_range).count
|
||||
returned_in_period = contacts
|
||||
.where('first_interaction_at < ?', period_range.begin)
|
||||
.where(last_interaction_at: period_range)
|
||||
.count
|
||||
|
||||
{
|
||||
new: new_in_period,
|
||||
returned: returned_in_period,
|
||||
total_touches: new_in_period + returned_in_period
|
||||
}
|
||||
end
|
||||
|
||||
# Pix gerado e pago no período (globais, baseado em PixCharge)
|
||||
def pix_counts
|
||||
period_range = @period_start.beginning_of_day..@period_end.end_of_day
|
||||
|
||||
generated = Captain::PixCharge.where(created_at: period_range).where(unit_id: account_unit_ids).count
|
||||
paid = Captain::PixCharge.where(paid_at: period_range).where(unit_id: account_unit_ids).count
|
||||
|
||||
conversion = generated.zero? ? 0.0 : (paid.to_f / generated).round(4)
|
||||
|
||||
{
|
||||
generated: generated,
|
||||
paid: paid,
|
||||
conversion_rate: conversion
|
||||
}
|
||||
end
|
||||
|
||||
def account_unit_ids
|
||||
@account_unit_ids ||= Captain::Unit.where(account_id: @account.id).pluck(:id)
|
||||
end
|
||||
|
||||
# Taxa de retorno: dos contatos cuja PRIMEIRA interação foi há {N} dias atrás
|
||||
# (ex: [N-step, N]), quantos voltaram a interagir nos últimos {step} dias.
|
||||
# Para 30d: cohort da semana entre 30-37d atrás, retorno nos últimos 7d.
|
||||
def retention_rates
|
||||
{
|
||||
last_30d: retention_rate(RETENTION_30D_WINDOW, 7.days),
|
||||
last_90d: retention_rate(RETENTION_90D_WINDOW, 14.days)
|
||||
}
|
||||
end
|
||||
|
||||
def retention_rate(window, step)
|
||||
now = Time.current
|
||||
cohort_start = now - window - step
|
||||
cohort_end = now - window
|
||||
|
||||
cohort = contacts
|
||||
.where(first_interaction_at: cohort_start..cohort_end)
|
||||
cohort_size = cohort.count
|
||||
return 0.0 if cohort_size.zero?
|
||||
|
||||
returned = cohort.where('last_interaction_at > first_interaction_at + interval \'1 day\'')
|
||||
.where('last_interaction_at >= ?', now - step)
|
||||
.count
|
||||
|
||||
(returned.to_f / cohort_size).round(4)
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user