Mede por inbox/período: leads novos (1ª conversa do contato em qualquer inbox da rede), retorno (conversa anterior resolved há >24h) e outras (conversa anterior open ou resolved <24h). Categorias somadas batem com o conversations_count nativo do report — bucket "outras" garante o fechamento. - Novo builder V2::Reports::InboxLeadsSummaryBuilder com CTE única - Endpoint GET /api/v2/accounts/:id/reports/inbox_leads_summary - Tabs no InboxReportsShow (Visão Geral | Novas × Retorno) - Componente InboxLeadsReport com 3 metric cards + barras empilhadas - API client + Pinia (state/getters/actions/mutations) - i18n en + pt_BR - RSpec do builder cobrindo classificação e isolamento por inbox Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
3.2 KiB
Ruby
112 lines
3.2 KiB
Ruby
class V2::Reports::InboxLeadsSummaryBuilder
|
|
include DateRangeHelper
|
|
|
|
ALLOWED_GROUP_BY = %w[day week month].freeze
|
|
RETURN_THRESHOLD = '24 hours'.freeze
|
|
|
|
attr_reader :account, :params
|
|
|
|
def initialize(account, params)
|
|
@account = account
|
|
@params = params
|
|
end
|
|
|
|
def build
|
|
return [] if range.blank? || inbox.blank?
|
|
|
|
rows = ActiveRecord::Base.connection.exec_query(
|
|
ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings])
|
|
)
|
|
|
|
rows.map do |row|
|
|
{
|
|
period: row['period'].iso8601,
|
|
new_leads: row['new_leads'].to_i,
|
|
returning: row['returning'].to_i,
|
|
others: row['others'].to_i
|
|
}
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def inbox
|
|
@inbox ||= account.inboxes.find_by(id: params[:inbox_id])
|
|
end
|
|
|
|
def group_by
|
|
value = params[:group_by].to_s
|
|
ALLOWED_GROUP_BY.include?(value) ? value : 'day'
|
|
end
|
|
|
|
def sql_bindings
|
|
{
|
|
account_id: account.id,
|
|
inbox_id: inbox.id,
|
|
since: range.begin,
|
|
until_t: range.end,
|
|
group_by: group_by,
|
|
return_threshold: RETURN_THRESHOLD
|
|
}
|
|
end
|
|
|
|
# Single CTE to classify each conversation in the period as:
|
|
# * new_leads: contact has no prior conversation in any inbox of the account
|
|
# * returning: contact had a prior conversation whose latest 'conversation_resolved'
|
|
# event occurred more than 24h before the new conversation
|
|
# * others: prior conversation existed but was not resolved or was resolved <24h ago
|
|
# rubocop:disable Metrics/MethodLength
|
|
def sql
|
|
<<~SQL.squish
|
|
WITH period_conversations AS (
|
|
SELECT id, contact_id, created_at
|
|
FROM conversations
|
|
WHERE account_id = :account_id
|
|
AND inbox_id = :inbox_id
|
|
AND created_at >= :since
|
|
AND created_at < :until_t
|
|
),
|
|
classified AS (
|
|
SELECT
|
|
c.id,
|
|
c.created_at,
|
|
EXISTS (
|
|
SELECT 1 FROM conversations prev
|
|
WHERE prev.contact_id = c.contact_id
|
|
AND prev.account_id = :account_id
|
|
AND prev.id < c.id
|
|
) AS has_prior,
|
|
(
|
|
SELECT MAX(re.created_at)
|
|
FROM reporting_events re
|
|
INNER JOIN conversations prev ON prev.id = re.conversation_id
|
|
WHERE re.name = 'conversation_resolved'
|
|
AND prev.contact_id = c.contact_id
|
|
AND prev.account_id = :account_id
|
|
AND prev.id < c.id
|
|
) AS latest_prior_resolution_at
|
|
FROM period_conversations c
|
|
)
|
|
SELECT
|
|
date_trunc(:group_by, created_at) AS period,
|
|
COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads,
|
|
COUNT(*) FILTER (
|
|
WHERE has_prior
|
|
AND latest_prior_resolution_at IS NOT NULL
|
|
AND latest_prior_resolution_at < created_at - (:return_threshold)::interval
|
|
) AS returning,
|
|
COUNT(*) FILTER (
|
|
WHERE has_prior
|
|
AND (
|
|
latest_prior_resolution_at IS NULL
|
|
OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval
|
|
)
|
|
) AS others
|
|
FROM classified
|
|
GROUP BY period
|
|
ORDER BY period
|
|
SQL
|
|
end
|
|
# rubocop:enable Metrics/MethodLength
|
|
end
|