feat(analytics): воронка коммуникаций на странице «Аналитика»

Владелец: «нужно видеть, как всё происходит: push отправили — реакции не было, потом SMS,
потом звонок; с какого раза человек ответил на звонок, кто вообще не поднимает трубку».

Один RPC admin_comms_funnel(p_days, p_date_from, p_date_to) — период считается как у
admin_automation_economics (30 = группы, созданные за 30 дней; 0 = всё время) — отдаёт:
этапы цепочки по когорте (группы → push → первое SMS → открыли ссылку → позвонили →
взяли трубку → согласовано → в ручную → выдано), разбивку звонков по попыткам
(всего/взяли/согласовали/не взяли + % ответов), каналы подтверждения (робот / менеджер /
ссылка), список «не поднимают трубку» и «открыли ссылку, но не подтвердили», и «silent» —
кого не достали ни ссылкой, ни звонком.

Блок CommsFunnelBlock на «Аналитике» под KPI-карточками, те же табы периода, mobile-first,
время в МСК. За 30 дней: 396 групп, первое SMS 340, открыли ссылку 1, звонили 26,
взяли трубку 14, согласовано 128, в ручную 22, выдано 357; 265 (66.9%) не достали никак.
С попыток: 1-я — 42 ответа из 64, 2-я — 2 из 6, 3-я — 4 из 4.

Тест: src/components/admin/analytics/CommsFunnelBlock.test.jsx (7 проверок, фикстура по
реальному ответу RPC). SW: v123 -> v124.
This commit is contained in:
Hermes 2026-09-29 10:23:01 +02:00
parent 5979b03f9f
commit 188db318e3
6 changed files with 567 additions and 4 deletions

View File

@ -1,7 +1,7 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v123";
const STATIC_CACHE = "construction-delivery-static-v124";
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];

View File

@ -16,6 +16,7 @@ import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats';
import { usePickupStats } from '../../hooks/usePickupStats';
import { PickupStatsPanel } from './PickupStatsPanel';
import { CommsFunnelBlock } from './analytics/CommsFunnelBlock';
// ── Mobile Detection Hook ───────────────────────────────────────────────────
const useIsMobile = () => {
@ -129,7 +130,7 @@ const SectionHeader = ({ title, subtitle, mobile }) => (
export const AdminDashboard = () => {
const [period, setPeriod] = useState('7d');
const mobile = useIsMobile();
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
const { stats, statusDist, dailyTrend, driverStats, economics, funnel, isLoading, error, refetch } = useAdminStats(period);
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
const periodLabel = PERIOD_LABELS[period] || '';
@ -238,6 +239,9 @@ export const AdminDashboard = () => {
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
</div>
{/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */}
<CommsFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>

View File

@ -0,0 +1,302 @@
/**
* @file CommsFunnelBlock.jsx
* @description Воронка коммуникаций для страницы «Аналитика» (mega_admin/admin).
* Показывает цепочку по когорте групп за период: push → SMS → открытие ссылки →
* звонки робота (с какой попытки берут трубку) → согласование → ручная работа → выдача.
* Плюс два рабочих списка: «не поднимают трубку» и «открыли ссылку, но не подтвердили».
* Данные — один вызов admin_comms_funnel (см. supabase/admin_comms_funnel.sql).
*/
import React from 'react';
import { Panel } from '../../UI/Panel';
const STAGE_COLORS = {
groups: '#94a3b8',
push: '#a855f7',
sms1: '#3b82f6',
opened: '#06b6d4',
called: '#6366f1',
answered: '#14b8a6',
confirmed: '#22c55e',
manual: '#eab308',
done: '#10b981',
};
const CHANNEL_LABELS = {
autocall: 'Робот-звонок',
manual: 'Менеджер',
link: 'Ссылка из SMS',
automation: 'Автоматически',
};
const STATUS_LABELS = {
first_sms_sent: 'Ждёт решения',
second_sms_sent: 'Второе SMS',
link_ready: 'Ссылка готова',
confirmed: 'Согласовано',
manual_required: 'В ручной работе',
completed: 'Завершено',
opt_out: 'Отказ',
};
const KIND_LABELS = { pickup: 'самовывоз', delivery: 'доставка' };
// Время показываем в МСК — как во всех отчётах.
const fmtDateTime = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
});
};
const SectionHeader = ({ title, subtitle, mobile }) => (
<div style={{ marginBottom: '0.6rem' }}>
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
{title}
</h3>
{subtitle && (
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>{subtitle}</div>
)}
</div>
);
const StageRow = ({ stage, max, mobile, fontSize }) => {
const pct = max > 0 ? Math.max(0, (stage.value / max) * 100) : 0;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.35rem' }}>
<div style={{
flex: '0 0 auto', width: mobile ? '96px' : '150px', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', textAlign: 'right', lineHeight: 1.2,
}}>
{stage.label}
</div>
<div style={{
flex: '1 1 auto', height: mobile ? '16px' : '22px',
background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden',
}}>
<div style={{
width: pct + '%', height: '100%', background: STAGE_COLORS[stage.key] || '#3b82f6',
borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0',
}} />
</div>
<div style={{
flex: '0 0 auto', width: mobile ? '62px' : '86px', fontSize: fontSize.s,
display: 'flex', gap: '0.3rem', alignItems: 'baseline',
}}>
<strong style={{ color: 'var(--color-text)' }}>{stage.value}</strong>
<span style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs }}>{stage.pct}%</span>
</div>
</div>
);
};
const Chip = ({ label, value, color, fontSize }) => (
<div style={{
display: 'flex', alignItems: 'baseline', gap: '0.35rem', padding: '0.3rem 0.55rem',
border: '1px solid var(--color-border, #334155)', borderRadius: '10px',
fontSize: fontSize.xs, color: 'var(--color-text-muted)', whiteSpace: 'nowrap',
}}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: color, display: 'inline-block' }} />
<span>{label}</span>
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{value}</strong>
</div>
);
const Th = ({ children, mobile, fontSize, align = 'left' }) => (
<th style={{
textAlign: align, padding: mobile ? '0.3rem 0.35rem' : '0.4rem 0.5rem',
fontSize: '13px', fontWeight: 600, color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border, #334155)', whiteSpace: 'nowrap',
}}>
{children}
</th>
);
const Td = ({ children, mobile, fontSize, muted = false, align = 'left' }) => (
<td style={{
textAlign: align, padding: mobile ? '0.3rem 0.35rem' : '0.4rem 0.5rem',
fontSize: fontSize.s, color: muted ? 'var(--color-text-muted)' : 'var(--color-text)',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.35))', whiteSpace: 'nowrap',
}}>
{children}
</td>
);
const EmptyRow = ({ cols, mobile, fontSize, text }) => (
<tr>
<td colSpan={cols} style={{
padding: '0.6rem', textAlign: 'center', fontSize: fontSize.s,
color: 'var(--color-text-muted)',
}}>
{text}
</td>
</tr>
);
export const CommsFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabel = '' }) => {
const fontSize = mobile
? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' }
: { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' };
const stages = funnel?.stages || [];
const max = stages.length ? Math.max(...stages.map((s) => Number(s.value) || 0)) : 0;
const silent = funnel?.silent || { count: 0, pct: 0 };
const attempts = funnel?.attempts || [];
const channels = funnel?.channels || [];
const noAnswer = funnel?.no_answer_list || [];
const openedNoConfirm = funnel?.opened_no_confirm || [];
const pushSubs = funnel?.push_subs ?? 0;
if (isLoading || !funnel) {
return (
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
<SectionHeader title="Воронка коммуникаций" subtitle={periodLabel} mobile={mobile} />
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.s, padding: '1rem 0' }}>Загружаю…</div>
</Panel>
);
}
return (
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
<SectionHeader
title="Воронка коммуникаций"
subtitle={periodLabel ? `${periodLabel} · как проходили контакты с клиентами` : 'Как проходили контакты с клиентами'}
mobile={mobile}
/>
{/* ── Этапы цепочки ───────────────────────────────────────────────── */}
<div style={{ marginBottom: '0.75rem' }}>
{stages.map((s) => (
<StageRow key={s.key} stage={s} max={max} mobile={mobile} fontSize={fontSize} />
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.9rem' }}>
<Chip label="Не достали (ни ссылка, ни звонок)" value={`${silent.count} · ${silent.pct}%`} color="#ef4444" fontSize={fontSize} />
{stages.find((s) => s.key === 'push') && (
<Chip
label={stages.find((s) => s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'}
value={pushSubs}
color="#a855f7"
fontSize={fontSize}
/>
)}
</div>
{/* ── С какой попытки отвечают ─────────────────────────────────────── */}
<div style={{ marginBottom: '0.9rem', overflowX: 'auto' }}>
<div style={{ fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.35rem' }}>
С какой попытки отвечают
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th mobile={mobile} fontSize={fontSize}>Попытка</Th>
<Th mobile={mobile} fontSize={fontSize} align="right">Звонков</Th>
<Th mobile={mobile} fontSize={fontSize} align="right">Взяли трубку</Th>
<Th mobile={mobile} fontSize={fontSize} align="right">Согласовали</Th>
<Th mobile={mobile} fontSize={fontSize} align="right">Не взяли</Th>
</tr>
</thead>
<tbody>
{attempts.length === 0 ? (
<EmptyRow cols={5} mobile={mobile} fontSize={fontSize} text="За период робот не звонил" />
) : attempts.map((a) => (
<tr key={a.attempt}>
<Td mobile={mobile} fontSize={fontSize}>{a.attempt}-я</Td>
<Td mobile={mobile} fontSize={fontSize} align="right">{a.total}</Td>
<Td mobile={mobile} fontSize={fontSize} align="right">
{a.answered} <span style={{ color: 'var(--color-text-muted)' }}>({a.answered_pct}%)</span>
</Td>
<Td mobile={mobile} fontSize={fontSize} align="right">{a.confirmed}</Td>
<Td mobile={mobile} fontSize={fontSize} align="right" muted>{a.no_answer}</Td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Чем подтвердили ─────────────────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.9rem' }}>
{channels.length === 0 ? (
<div style={{ fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>Подтверждений за период нет</div>
) : channels.map((c) => (
<Chip
key={c.source}
label={CHANNEL_LABELS[c.source] || c.source}
value={c.count}
color={c.source === 'autocall' ? '#6366f1' : c.source === 'manual' ? '#eab308' : '#06b6d4'}
fontSize={fontSize}
/>
))}
</div>
{/* ── Не поднимают трубку ─────────────────────────────────────────── */}
<div style={{ marginBottom: '0.9rem', overflowX: 'auto' }}>
<div style={{ fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.35rem' }}>
Не поднимают трубку
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th mobile={mobile} fontSize={fontSize}>Телефон</Th>
<Th mobile={mobile} fontSize={fontSize}>Город</Th>
<Th mobile={mobile} fontSize={fontSize}>Тип</Th>
<Th mobile={mobile} fontSize={fontSize} align="right">Попыток</Th>
<Th mobile={mobile} fontSize={fontSize}>Последний звонок</Th>
<Th mobile={mobile} fontSize={fontSize}>Статус</Th>
</tr>
</thead>
<tbody>
{noAnswer.length === 0 ? (
<EmptyRow cols={6} mobile={mobile} fontSize={fontSize} text="Нет таких номеров за период" />
) : noAnswer.map((n) => (
<tr key={n.phone}>
<Td mobile={mobile} fontSize={fontSize}>{n.phone}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{n.town}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{KIND_LABELS[n.kind] || n.kind}</Td>
<Td mobile={mobile} fontSize={fontSize} align="right">{n.attempts}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{fmtDateTime(n.last_call)}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{STATUS_LABELS[n.status] || n.status || '—'}</Td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Открыли ссылку, но не подтвердили ───────────────────────────── */}
<div style={{ overflowX: 'auto' }}>
<div style={{ fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.35rem' }}>
Открыли ссылку, но не подтвердили
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th mobile={mobile} fontSize={fontSize}>Заказ</Th>
<Th mobile={mobile} fontSize={fontSize}>Клиент</Th>
<Th mobile={mobile} fontSize={fontSize}>Телефон</Th>
<Th mobile={mobile} fontSize={fontSize} align="right">Открытий</Th>
<Th mobile={mobile} fontSize={fontSize}>Последнее</Th>
</tr>
</thead>
<tbody>
{openedNoConfirm.length === 0 ? (
<EmptyRow cols={5} mobile={mobile} fontSize={fontSize} text="Все, кто открыл ссылку, подтвердили" />
) : openedNoConfirm.map((o) => (
<tr key={o.num + o.phone}>
<Td mobile={mobile} fontSize={fontSize}>{o.num}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{o.name}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{o.phone}</Td>
<Td mobile={mobile} fontSize={fontSize} align="right">{o.opens}</Td>
<Td mobile={mobile} fontSize={fontSize} muted>{fmtDateTime(o.opened)}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
);
};
export default CommsFunnelBlock;

View File

@ -0,0 +1,105 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { CommsFunnelBlock } from "./CommsFunnelBlock";
// Фикстура повторяет ответ admin_comms_funnel (30 дней, реальные цифры на 29.09.2026).
const funnel = {
period: { days: 30, from: null, to: null },
stages: [
{ key: "groups", label: "Групп в работе", value: 396, pct: 100 },
{ key: "push", label: "Push отправлен", value: 0, pct: 0 },
{ key: "sms1", label: "Первое SMS", value: 340, pct: 85.9 },
{ key: "opened", label: "Открыли ссылку", value: 1, pct: 0.3 },
{ key: "called", label: "Робот позвонил", value: 26, pct: 6.6 },
{ key: "answered", label: "Взяли трубку", value: 14, pct: 3.5 },
{ key: "confirmed", label: "Согласовано", value: 128, pct: 32.3 },
{ key: "manual", label: "В ручную работу", value: 22, pct: 5.6 },
{ key: "done", label: "Выдано/доставлено", value: 357, pct: 90.2 },
],
silent: { count: 265, pct: 66.9 },
second_sms: { count: 0, pct: 0 },
push_subs: 6,
attempts: [
{ attempt: 1, total: 64, answered: 42, confirmed: 19, no_answer: 22, answered_pct: 65.6, confirmed_pct: 29.7 },
{ attempt: 2, total: 6, answered: 2, confirmed: 0, no_answer: 4, answered_pct: 33.3, confirmed_pct: 0 },
{ attempt: 3, total: 4, answered: 4, confirmed: 2, no_answer: 0, answered_pct: 100, confirmed_pct: 50 },
],
channels: [
{ source: "link", count: 103 },
{ source: "manual", count: 19 },
{ source: "autocall", count: 6 },
],
no_answer_list: [
{ phone: "9787617568", town: "Севастополь", kind: "pickup", attempts: 2, last_call: "2026-09-28T11:15:13Z", status: "manual_required" },
],
opened_no_confirm: [
{ num: "9788324113|28.09.2026", name: "ЗАКАЗЧИК", phone: "9788324113", opens: 14, opened: "2026-09-28T10:04:52Z" },
],
};
const emptyFunnel = {
stages: [],
silent: { count: 0, pct: 0 },
push_subs: 0,
attempts: [],
channels: [],
no_answer_list: [],
opened_no_confirm: [],
};
describe("CommsFunnelBlock", () => {
const html = renderToStaticMarkup(<CommsFunnelBlock funnel={funnel} mobile={false} periodLabel="30 дней" />);
it("показывает все этапы цепочки со значениями и процентами", () => {
for (const label of ["Групп в работе", "Push отправлен", "Первое SMS", "Открыли ссылку",
"Робот позвонил", "Взяли трубку", "Согласовано", "В ручную работу", "Выдано/доставлено"]) {
expect(html).toContain(label);
}
expect(html).toContain("396");
expect(html).toContain("85.9%");
expect(html).toContain("340");
});
it("показывает, сколько не достали и что push не используется", () => {
expect(html).toContain("Не достали (ни ссылка, ни звонок)");
expect(html).toContain("265 · 66.9%");
expect(html).toContain("Push не используется, подписок");
});
it("разбивает звонки по попыткам с процентом ответов", () => {
expect(html).toContain("С какой попытки отвечают");
expect(html).toContain("1-я");
expect(html).toContain("65.6%");
expect(html).toContain("3-я");
});
it("показывает каналы подтверждения человеческими подписями", () => {
expect(html).toContain("Робот-звонок");
expect(html).toContain("Менеджер");
expect(html).toContain("Ссылка из SMS");
});
it("перечисляет тех, кто не берёт трубку, и тех, кто открыл ссылку без подтверждения", () => {
expect(html).toContain("Не поднимают трубку");
expect(html).toContain("9787617568");
expect(html).toContain("самовывоз");
expect(html).toContain("В ручной работе");
expect(html).toContain("Открыли ссылку, но не подтвердили");
expect(html).toContain("9788324113");
});
it("на пустых данных пишет понятные заглушки, а не пустые таблицы", () => {
const empty = renderToStaticMarkup(<CommsFunnelBlock funnel={emptyFunnel} mobile={true} periodLabel="Сегодня" />);
expect(empty).toContain("За период робот не звонил");
expect(empty).toContain("Нет таких номеров за период");
expect(empty).toContain("Все, кто открыл ссылку, подтвердили");
expect(empty).toContain("Подтверждений за период нет");
});
it("показывает заглушку загрузки, пока данных нет", () => {
const loading = renderToStaticMarkup(<CommsFunnelBlock funnel={null} isLoading mobile={false} periodLabel="7 дней" />);
expect(loading).toContain("Воронка коммуникаций");
expect(loading).toContain("Загружаю…");
});
});

View File

@ -18,6 +18,7 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) =>
const [dailyTrend, setDailyTrend] = useState([]);
const [driverStats, setDriverStats] = useState([]);
const [economics, setEconomics] = useState(null);
const [funnel, setFunnel] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@ -32,18 +33,20 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) =>
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
};
const [s, sd, dt, ds, econ] = await Promise.all([
const [s, sd, dt, ds, econ, fn] = await Promise.all([
rpcCall("admin_delivery_stats", params),
rpcCall("admin_status_distribution", params),
rpcCall("admin_daily_trend", params),
rpcCall("admin_driver_stats", params),
rpcCall("admin_automation_economics", params),
rpcCall("admin_comms_funnel", params),
]);
setStats(s?.[0] || null);
setStatusDist(sd || []);
setDailyTrend(dt || []);
setDriverStats(ds || []);
setEconomics(econ?.[0] || null);
setFunnel(fn || null);
} catch (e) {
setError(e.message || "Ошибка загрузки статистики");
} finally {
@ -53,5 +56,5 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) =>
useEffect(() => { fetchAll(); }, [fetchAll]);
return { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch: fetchAll };
return { stats, statusDist, dailyTrend, driverStats, economics, funnel, isLoading, error, refetch: fetchAll };
};

View File

@ -0,0 +1,149 @@
-- admin_comms_funnel — воронка коммуникаций для страницы «Аналитика» (mega_admin/admin).
-- Один вызов отдаёт всё, что нужно блоку «Воронка коммуникаций»:
-- stages — этапы цепочки по когорте групп за период (push → SMS → звонки → согласование → ручная → выдача);
-- attempts — с какой попытки берут трубку и согласовывают;
-- channels — чем подтвердили (робот / менеджер / ссылка);
-- lists — «не поднимают трубку» и «открыли ссылку, но не подтвердили» (по 10 записей, для работы менеджера);
-- silent — сколько не достали ни ссылкой, ни звонком.
-- Конвенция периода — как у admin_automation_economics: p_days=30 → группы, созданные за 30 дней;
-- p_days=0 → всё время; p_date_from/p_date_to — явное окно (перекрывает p_days).
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
),
inv as (
select i.* from delivery_invitations i join cohort c on c.id = i.order_group_id
),
-- группы, которых не достали ни ссылкой, ни ответом на звонок
silent as (
select c.id
from cohort c
where c.notification_status not in ('confirmed', 'opt_out')
and not exists (select 1 from inv i where i.order_group_id = c.id and i.opened_at is not null)
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(*) from inv i where i.opened_at is not null) 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 cohort c where c.notification_status = 'confirmed') 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 silent) 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_cnt::numeric / groups_cnt * 100, 1) else 0 end as silent_pct
from stages
),
attempt_rows as (
select attempt_no,
count(*) as total,
count(*) filter (where outcome <> 'no_answer') as answered,
count(*) filter (where outcome = 'confirmed') as confirmed,
count(*) filter (where outcome = 'no_answer') as no_answer
from calls
group by attempt_no
order by attempt_no
),
channel_rows as (
select coalesce(
c.delivery_date_source,
case when exists (select 1 from calls l where l.order_group_id = c.id and l.changed)
then 'autocall' end,
'link'
) as source,
count(*) as cnt
from cohort c
where c.notification_status = 'confirmed'
group by 1
),
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, i.opened_at as opened, i.access_count as opens
from inv i
where i.opened_at is not null and i.confirmed_at is null
order by i.opened_at desc
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_cnt, 'pct', p.silent_pct),
'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.source, 'count', ch.cnt)
order by ch.cnt desc) from channel_rows ch), '[]'::jsonb),
'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)
)
from stages s, pct p;
$function$;
comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is
'Воронка коммуникаций для аналитики: этапы push → SMS → ссылка → звонки → согласование → ручная работа → выдача.';