supersam/supabase/admin_comms_funnel.sql

245 lines
17 KiB
SQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- admin_comms_funnel — воронка коммуникаций для страницы «Аналитика» (mega_admin/admin).
-- Один вызов отдаёт всё, что нужно блокам «Воронка коммуникаций» и «Путь заказа»:
-- stages — этапы цепочки по когорте групп за период (push → SMS → ссылка → звонки → согласование → ручная → выдача);
-- attempts — с какой попытки берут трубку и согласовывают;
-- channels — КТО подтвердил (робот / логист / ссылка / push): считается по событию, а не по текущему статусу;
-- lists — «не поднимают трубку» и «открыли ссылку, но не подтвердили» (по 10 записей, для работы менеджера);
-- silent — кого не достали ни ссылкой, ни звонком (отдельно: ещё в работе и уже закрытые).
-- Конвенция периода — как у admin_automation_economics: p_days=30 → группы, созданные за 30 дней;
-- p_days=0 → всё время; p_date_from/p_date_to — явное окно (перекрывает p_days).
--
-- ⚠️ Правила, без которых цифры врут (проверено на боевых данных 02.10.2026):
-- 1. Тестовые звонки (autocall_log.was_test_mode) в статистику НЕ идут: клиенту не звонили.
-- 2. «Открыли ссылку» нельзя считать по opened_at — его пишет только edge-функция
-- get-delivery-invitation, а страница клиента ходит RPC. Фактический вход в ссылку —
-- access_count > 0 / last_accessed_at, подтверждение — confirmed_at.
-- 3. «Согласовано» и канал согласования считаются по СОБЫТИЮ (delivery_date_source /
-- подтверждённое приглашение / успешный звонок робота), а не по текущему
-- notification_status: после выдачи заказ уходит в 'completed' и из старой
-- статистики «сгорал» задним числом.
-- 4. Канал согласования у обоих блоков («Чем подтвердили» и «Как согласовали дату»)
-- считается ОДНИМ классификатором (confirm_channels) — иначе блоки расходятся.
create or replace function public.admin_comms_funnel(
p_days integer default 30,
p_date_from timestamp with time zone default null,
p_date_to timestamp with time zone default null
) returns jsonb
language sql
stable
security definer
set search_path to 'public', 'extensions'
as $function$
with cohort as (
select og.*
from order_groups og
where og.created_at >= coalesce(
p_date_from,
case when p_days = 0 then '-infinity'::timestamptz
else now() - make_interval(days => p_days) end)
and og.created_at <= coalesce(p_date_to, now())
),
calls as (
-- только боевые звонки: тестовые набирают номер владельца и к клиенту отношения не имеют
select l.* from autocall_log l join cohort c on c.id = l.order_group_id
where not coalesce(l.was_test_mode, false)
),
inv as (
select i.* from delivery_invitations i join cohort c on c.id = i.order_group_id
),
-- ссылка реально открыта (клиент заходил на страницу выбора слота)
opened_inv as (
select i.* from inv i
where i.opened_at is not null
or coalesce(i.access_count, 0) > 0
or i.last_accessed_at is not null
or i.confirmed_at is not null
),
-- кто подтвердил: один классификатор для обоих блоков аналитики
confirm_channels as (
select c.id,
case
when c.delivery_date_source = 'autocall' then 'call'
when c.delivery_date_source = 'manual' then 'manual'
when c.delivery_date_source = 'push' then 'push'
when c.delivery_date_source in ('sms', 'client', 'messenger') then 'sms'
when exists (select 1 from inv i
where i.order_group_id = c.id and i.confirmed_at is not null) then 'link'
when c.manual_confirmation_at is not null then 'manual'
when exists (select 1 from calls l
where l.order_group_id = c.id
and l.outcome in ('confirmed', 'confirmed_silent', 'confirmed_kept_previous')) then 'call'
else null
end as channel,
(c.delivery_date is not null or c.pickup_date is not null) as has_date
from cohort c
),
-- не достали ни ссылкой, ни ответом на звонок
unreached as (
select c.id,
(c.delivery_status in ('delivered', 'picked_up', 'cancelled', 'paid_storage')) as closed
from cohort c
where c.notification_status not in ('confirmed', 'opt_out')
and not exists (select 1 from opened_inv i where i.order_group_id = c.id)
and not exists (select 1 from calls l
where l.order_group_id = c.id
and l.outcome in ('confirmed', 'no_input_day', 'no_input_half'))
),
stages as (
select
(select count(*) from cohort) as groups_cnt,
(select count(*) from cohort c where exists (select 1 from push_log p where p.order_group_id = c.id)) as push_cnt,
(select count(*) from cohort c where c.first_sms_sent_at is not null) as sms1_cnt,
(select count(*) from cohort c where c.second_sms_sent_at is not null) as sms2_cnt,
(select count(distinct i.order_group_id) from opened_inv i) as opened_cnt,
(select count(*) from cohort c where exists (select 1 from calls l where l.order_group_id = c.id)) as called_cnt,
(select count(*) from cohort c where exists (
select 1 from calls l where l.order_group_id = c.id
and l.outcome in ('confirmed', 'no_input_day', 'no_input_half'))) as answered_cnt,
-- согласовано = есть подтверждение по событию (сумма каналов), а не текущий статус
(select count(*) from confirm_channels cc where cc.channel is not null) as confirmed_cnt,
(select count(*) from cohort c where c.notification_status = 'manual_required') as manual_cnt,
(select count(*) from cohort c where c.delivery_status in ('delivered', 'picked_up')) as done_cnt,
(select count(*) from unreached where not closed) as silent_open_cnt,
(select count(*) from unreached where closed) as silent_closed_cnt,
(select count(*) from unreached) as silent_cnt
),
pct as (
select
case when groups_cnt > 0 then round(push_cnt::numeric / groups_cnt * 100, 1) else 0 end as push_pct,
case when groups_cnt > 0 then round(sms1_cnt::numeric / groups_cnt * 100, 1) else 0 end as sms1_pct,
case when groups_cnt > 0 then round(opened_cnt::numeric / groups_cnt * 100, 1) else 0 end as opened_pct,
case when groups_cnt > 0 then round(called_cnt::numeric / groups_cnt * 100, 1) else 0 end as called_pct,
case when groups_cnt > 0 then round(answered_cnt::numeric / groups_cnt * 100, 1) else 0 end as answered_pct,
case when groups_cnt > 0 then round(confirmed_cnt::numeric / groups_cnt * 100, 1) else 0 end as confirmed_pct,
case when groups_cnt > 0 then round(manual_cnt::numeric / groups_cnt * 100, 1) else 0 end as manual_pct,
case when groups_cnt > 0 then round(silent_open_cnt::numeric / groups_cnt * 100, 1) else 0 end as silent_pct
from stages
),
path as (
select
count(*) as created,
count(*) filter (where first_sms_sent_at is not null) as sms,
count(*) filter (where delivery_date is not null or pickup_date is not null) as dated,
count(*) filter (where (delivery_date is not null or pickup_date is not null)
and delivery_status in ('delivered', 'picked_up')) as chain_done,
count(*) filter (where delivery_status in ('delivered', 'picked_up')
and delivery_date is null and pickup_date is null) as bypass_done,
count(*) filter (where delivery_status = 'paid_storage') as paid_storage,
count(*) filter (where delivery_status = 'cancelled') as cancelled,
-- закрыли без согласования (в т.ч. выдача, закрытая из 1С): клиент дату не выбирал
count(*) filter (where notification_status = 'closed_without_confirmation') as no_confirmation,
count(*) filter (where delivery_type = 'pickup') as pickup_cnt,
count(*) filter (where delivery_type = 'delivery') as delivery_cnt
from cohort
),
attempt_rows as (
select attempt_no,
count(*) as total,
count(*) filter (where outcome in ('confirmed', 'confirmed_silent', 'confirmed_kept_previous',
'no_input_day', 'no_input_half')) as answered,
-- все три подтверждающих исхода, как в KPI (иначе «молчание» не видно)
count(*) filter (where outcome in ('confirmed', 'confirmed_silent', 'confirmed_kept_previous')) as confirmed,
count(*) filter (where outcome = 'no_answer') as no_answer
from calls
group by attempt_no
order by attempt_no
),
no_answer_rows as (
select c.customer_phone as phone, coalesce(nullif(c.town, ''), '—') as town,
c.delivery_type as kind, count(*) as attempts,
max(l.created_at) as last_call, c.notification_status as status
from calls l join cohort c on c.id = l.order_group_id
group by 1, 2, 3, c.id, c.notification_status
having count(*) filter (where l.outcome <> 'no_answer') = 0
order by attempts desc, max(l.created_at) desc
limit 10
),
opened_rows as (
select i.order_number as num, coalesce(nullif(i.customer_name, ''), '—') as name,
i.customer_phone as phone,
coalesce(i.opened_at, i.last_accessed_at) as opened,
greatest(coalesce(i.access_count, 0), case when i.opened_at is not null then 1 else 0 end) as opens
from inv i
where i.confirmed_at is null
and (i.opened_at is not null or coalesce(i.access_count, 0) > 0 or i.last_accessed_at is not null)
order by coalesce(i.last_accessed_at, i.opened_at) desc nulls last
limit 10
)
select jsonb_build_object(
'period', jsonb_build_object('days', p_days, 'from', p_date_from, 'to', p_date_to),
'stages', jsonb_build_array(
jsonb_build_object('key', 'groups', 'label', 'Групп в работе', 'value', s.groups_cnt, 'pct', 100),
jsonb_build_object('key', 'push', 'label', 'Push отправлен', 'value', s.push_cnt, 'pct', p.push_pct),
jsonb_build_object('key', 'sms1', 'label', 'Первое SMS', 'value', s.sms1_cnt, 'pct', p.sms1_pct),
jsonb_build_object('key', 'opened', 'label', 'Открыли ссылку', 'value', s.opened_cnt, 'pct', p.opened_pct),
jsonb_build_object('key', 'called', 'label', 'Робот позвонил', 'value', s.called_cnt, 'pct', p.called_pct),
jsonb_build_object('key', 'answered', 'label', 'Взяли трубку', 'value', s.answered_cnt, 'pct', p.answered_pct),
jsonb_build_object('key', 'confirmed', 'label', 'Согласовано', 'value', s.confirmed_cnt,'pct', p.confirmed_pct),
jsonb_build_object('key', 'manual', 'label', 'В ручную работу', 'value', s.manual_cnt, 'pct', p.manual_pct),
jsonb_build_object('key', 'done', 'label', 'Выдано/доставлено','value', s.done_cnt, 'pct', case when s.groups_cnt > 0 then round(s.done_cnt::numeric / s.groups_cnt * 100, 1) else 0 end)
),
'silent', jsonb_build_object(
'count', s.silent_open_cnt, -- ещё в работе, ответа не было
'pct', p.silent_pct,
'closed_count', s.silent_closed_cnt, -- уже закрыты, но клиент так и не ответил
'unreached_total', s.silent_cnt
),
'push_subs', (select count(*) from push_subscriptions),
'second_sms', jsonb_build_object('count', s.sms2_cnt,
'pct', case when s.groups_cnt > 0 then round(s.sms2_cnt::numeric / s.groups_cnt * 100, 1) else 0 end),
'attempts', coalesce((select jsonb_agg(jsonb_build_object(
'attempt', a.attempt_no, 'total', a.total, 'answered', a.answered,
'confirmed', a.confirmed, 'no_answer', a.no_answer,
'answered_pct', case when a.total > 0 then round(a.answered::numeric / a.total * 100, 1) else 0 end,
'confirmed_pct', case when a.total > 0 then round(a.confirmed::numeric / a.total * 100, 1) else 0 end
) order by a.attempt_no) from attempt_rows a), '[]'::jsonb),
-- Кто подтвердил за период (не зависит от текущего статуса заказа)
'channels', coalesce((select jsonb_agg(jsonb_build_object('source', ch.channel, 'count', ch.cnt)
order by ch.cnt desc)
from (select cc.channel, count(*) as cnt from confirm_channels cc
where cc.channel is not null group by 1) ch), '[]'::jsonb),
-- Сколько подтверждений всего (с датой и без) — знаменатель для каналов
'confirm_total', (select count(*) from confirm_channels cc where cc.channel is not null),
'confirm_no_date', (select count(*) from confirm_channels cc
where cc.channel is not null and not cc.has_date),
'no_answer_list', coalesce((select jsonb_agg(jsonb_build_object(
'phone', n.phone, 'town', n.town, 'kind', n.kind, 'attempts', n.attempts,
'last_call', n.last_call, 'status', n.status)) from no_answer_rows n), '[]'::jsonb),
'opened_no_confirm', coalesce((select jsonb_agg(jsonb_build_object(
'num', o.num, 'name', o.name, 'phone', o.phone,
'opened', o.opened, 'opens', o.opens)) from opened_rows o), '[]'::jsonb),
-- Путь заказа: воронка по цепочке (без даты водитель не едет) + то, что прошло мимо неё.
'order_path', jsonb_build_object(
'stages', jsonb_build_array(
jsonb_build_object('key', 'created', 'label', 'Групп в работе', 'value', pp.created, 'pct', 100),
jsonb_build_object('key', 'sms', 'label', 'Первое SMS', 'value', pp.sms,
'pct', case when pp.created > 0 then round(pp.sms::numeric / pp.created * 100, 1) else 0 end),
jsonb_build_object('key', 'dated', 'label', 'Дата согласована', 'value', pp.dated,
'pct', case when pp.created > 0 then round(pp.dated::numeric / pp.created * 100, 1) else 0 end),
jsonb_build_object('key', 'done', 'label', 'Выдано по цепочке', 'value', pp.chain_done,
'pct', case when pp.created > 0 then round(pp.chain_done::numeric / pp.created * 100, 1) else 0 end)
),
'bypass', jsonb_build_object('delivered', pp.bypass_done, 'paid_storage', pp.paid_storage,
'cancelled', pp.cancelled, 'no_confirmation', pp.no_confirmation),
'split', jsonb_build_object('pickup', pp.pickup_cnt, 'delivery', pp.delivery_cnt),
'sources', coalesce((select jsonb_agg(jsonb_build_object('source', ps.source, 'count', ps.cnt)
order by ps.cnt desc)
from (select coalesce(delivery_date_source, 'none') as source, count(*) as cnt
from cohort
where delivery_date is not null or pickup_date is not null
group by 1) ps), '[]'::jsonb),
-- Каким каналом согласовали дату: тот же классификатор, что и в блоке «Чем подтвердили»,
-- но только по заказам, где дата есть.
'channels', coalesce((select jsonb_agg(jsonb_build_object('channel', pc.channel, 'count', pc.cnt)
order by pc.cnt desc)
from (select cc.channel, count(*) as cnt from confirm_channels cc
where cc.channel is not null and cc.has_date group by 1) pc), '[]'::jsonb)
)
)
from stages s, pct p, path pp;
$function$;
comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is
'Воронка коммуникаций для аналитики: этапы push → SMS → ссылка → звонки → согласование → ручная работа → выдача. Тестовые звонки исключены, канал согласования считается по событию.';