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
|
post :generate, on: :collection
|
||||||
end
|
end
|
||||||
resource :funnel, only: [:show], controller: :funnel
|
resource :funnel, only: [:show], controller: :funnel
|
||||||
|
resource :retention, only: [:show], controller: :retention do
|
||||||
|
get :cohort
|
||||||
|
end
|
||||||
end
|
end
|
||||||
# Roleta da Sorte - tela de resgate na recepção + relatório semanal
|
# Roleta da Sorte - tela de resgate na recepção + relatório semanal
|
||||||
resource :roleta, only: [], controller: 'roleta' do
|
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