fix(analytics): честные цифры согласований — без согласования, тестовые звонки, открытия ссылки, единый канал
Триггер закрытия без даты больше не пишет «Согласовано» (новый статус closed_without_confirmation + backfill 128 строк). Робот/логист/ссылка считаются по событию одним классификатором (блоки больше не расходятся), тестовые звонки исключены, «Открыли ссылку» — по access_count, «не достали» разделено на «в работе» и «закрыто».
This commit is contained in:
parent
6dba84f22a
commit
bf2b3559ae
|
|
@ -0,0 +1,80 @@
|
|||
# SuperSam — проверка «Аналитики»: обзвоны и согласования
|
||||
|
||||
Проверка: 02.10.2026, МСК. Данные — последние 30 дней (02.09–02.10), живой вызов RPC на боевой БД (.8), плюс сверка с кодом, триггерами и живой сборкой фронта.
|
||||
|
||||
## Короткий вердикт
|
||||
|
||||
**По обзвонам (робот) цифры в целом верные.** Отдельные неточности: тестовые звонки попадают в статистику, в таблице попыток «Согласовали» считается по одному исходу, а KPI — по трём.
|
||||
|
||||
**По согласованиям — неверно.** Главная цифра «Согласовано» и блок «Чем подтвердили» показывают не подтверждения клиента, а служебный перевод заказа из ручной работы. Ошибка не в аналитике, а в данных: заказ, доставленный без согласования, помечается «Согласовано».
|
||||
|
||||
Две поверхности аналитики (одна страница!) дают разные цифры об одном и том же.
|
||||
|
||||
## Что показывает сейчас (живой `admin_comms_funnel(30)`)
|
||||
|
||||
Воронка коммуникаций:
|
||||
- Групп в работе 438 · Первое SMS 393 (89.7%) · **Открыли ссылку 1 (0.2%)** · Робот позвонил 80 (18.3%) · Взяли трубку 54 · Согласовано 186 (42.5%) · В ручную работу 18 · Выдано/доставлено 405 (92.5%) · Не достали 236 (53.9%)
|
||||
|
||||
Чем подтвердили: **Ссылка из SMS 137** · Менеджер 25 · Робот-звонок 24
|
||||
|
||||
Путь заказа (там же): Дата согласована 158 (36.1%) · мимо цепочки 259 · Как согласовали дату: **Логист 116** · Звонок робота 24 · SMS-ссылка 18 · Источники: Менеджер 116 · Робот-звонок 24
|
||||
|
||||
## 1. «Согласовано» на 137 заказах из 186 — это не подтверждения клиента
|
||||
|
||||
Что нашёл по этим 137 группам (status confirmed, источник даты пустой):
|
||||
- все 137 уже `delivered`;
|
||||
- даты нет ни в заказе (`delivery_date`/`pickup_date` пустые), ни в приглашении (состояние `awaiting_choice`, `confirmed_at` нет);
|
||||
- 131 из 137 получили первое SMS, звонки были у 16;
|
||||
- в истории: `delivery_status manual_confirmation_required → delivered` и одновременно `notification_status manual_required → confirmed`, источник — «system» (внешнее обновление, пришло из обмена).
|
||||
|
||||
Причина: триггер `clear_manual_required_on_action` (`supabase/sync-manual-status.sql`) при любом изменении статуса доставки переводит «нужен менеджер» в «согласовано». Заказ уехал без согласования — система записала подтверждение.
|
||||
|
||||
Масштаб: из 137 в блоке «Чем подтвердили» настоящих подтверждений по ссылке только 9 (у них есть подтверждённое приглашение), остальные 128 — подменённые триггером. Всего подтверждений по приглашениям за 30 дней — 20.
|
||||
|
||||
Это видно и в реестре: `orderGroupViews` рисует `confirmed` как «Согласовано», то есть владелец видит «Согласовано» по заказам, где клиент ничего не подтверждал.
|
||||
|
||||
## 2. Робот: почти верно, но есть расхождения
|
||||
|
||||
- Реально робот согласовал слот по **29** группам за 30 дней. В аналитике «Робот-звонок 24»: у 5 групп источник позже перезаписан логистом (робот согласовал, потом логист поменял дату) — в статистике эти подтверждения потеряны.
|
||||
- 1 группа согласована тестовым звонком и тоже считается.
|
||||
|
||||
## 3. Тестовые звонки не отфильтрованы
|
||||
|
||||
За 30 дней в `autocall_log` 37 тестовых строк (`was_test_mode`), 6 из них привязаны к группе — они попадают в «Робот позвонил / Взяли трубку» и в таблицу попыток.
|
||||
|
||||
Во вкладке «Коммуникации → Аналитика» фильтра по тесту нет вообще: «Звонков 179» (включая 37 тестовых), «Согласовали 40» (включая 11 тестовых), «Конверсия звонка 22%». Плюс период «Всё время» в этой вкладке на самом деле 90 дней.
|
||||
|
||||
## 4. «Согласовали» в таблице попыток считается не так, как в KPI
|
||||
|
||||
Таблица попыток берёт только `outcome='confirmed'` (16+7+6 = 29 звонков), а KPI и воронка — все три подтверждающих исхода (`confirmed`, `confirmed_silent`, `confirmed_kept_previous`). Подтверждения «молчанием» в таблице попыток не видны.
|
||||
|
||||
## 5. «Открыли ссылку» = 1 — метрика сломана
|
||||
|
||||
`opened_at` заполняет только edge-функция. По факту за 30 дней 76 приглашений имеют `access_count > 0` / `last_accessed_at` — страницу открывали, а `opened_at` стоит у 1. Шаг стоит в воронке между SMS и звонками, и читается как «клиенты не открывают ссылки» (0.2%).
|
||||
|
||||
## 6. Два блока на одной странице дают разные цифры
|
||||
|
||||
«Чем подтвердили» считает только текущий `notification_status='confirmed'` (186), «Как согласовали дату» — только заказы с датой (158), источники — все с датой (116+24), таблица попыток — звонки (29). Показательно про логиста: **25 в одном блоке и 116 в другом** — 91 группа с источником `manual` уже имеет статус `completed` и в первый блок не попадает. То есть цифра «кто согласовал» меняется задним числом по мере завершения заказов.
|
||||
|
||||
Отдельно: из 116 «логист согласовал» 97 уже получили SMS и 27 — звонки робота. Это ручное дорешивание после автоматики, а не «клиент сразу согласовал с логистом»; в отчёте это стоит различать.
|
||||
|
||||
## 7. Критическая находка: согласование по ссылке не работало с 29.09
|
||||
|
||||
`confirmDeliveryChoice` в `src/services/deliveryInvitationApi.js` читал переменную `src`, которой нет среди параметров (`p_src: src || null`) → `ReferenceError: src is not defined`. Клиент на странице выбора слота получал ошибку, выбор не сохранялся, канал согласования (`p_src`) в заказ не записывался.
|
||||
|
||||
Доказательства: живая сборка prod содержала `p_src:src` (переменной в области видимости нет); тест `deliveryInvitationApi.test.js` падал с `ReferenceError: src is not defined`; последнее подтверждение по ссылке в БД — 28.09 (миграция каналов — 29.09).
|
||||
|
||||
Исправлено и выложено: коммиты `f2a9249` и `6dba84f`. Prod dost.supersamsev.ru и dev dev.mkn8n.ru, SW v133. Тесты: 13/13 и 17/17. Каналы из ссылки (`sms`, `client`, `push`, `messenger`) теперь подписаны человеческими словами, а не сырыми ключами БД.
|
||||
|
||||
## Что предлагаю решить
|
||||
|
||||
1. **Убрать ложное «Согласовано»**: в триггере не ставить `confirmed`, когда дата не проставлена; такие заказы — отдельный исход «закрыт без согласования» (и в реестре, и в аналитике). Это меняет и реестр, поэтому нужно ваше решение.
|
||||
2. **«Открыли ссылку» считать по `last_accessed_at`/`access_count`** (76 вместо 1).
|
||||
3. **Исключить тестовые звонки** из аналитики по `was_test_mode`.
|
||||
4. **Свести обе поверхности к одному источнику цифр**: «кто согласовал» — по каналу (`autocall` / логист / ссылка), не потекущему статусу.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
Как блоки выглядят живьём в браузере (мобильный 390px, десктоп) — проверял данные напрямую в БД и RPC; UI глазами не кликал.
|
||||
|
||||
Проверки, по которым получены цифры: `/tmp/ss_analytics_check*.sql` (выполнены на .8 в контейнере `supabase-db`).
|
||||
|
|
@ -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-v133";
|
||||
const STATIC_CACHE = "construction-delivery-static-v134";
|
||||
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
|
||||
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ GROUPS_TO_CALL_SQL = """
|
|||
) t ON true
|
||||
WHERE og.delivery_status = 'pending_confirmation'
|
||||
AND og.autocall_opt_out = false
|
||||
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out')
|
||||
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out', 'closed_without_confirmation')
|
||||
-- Пауза после SMS (или после неудачной попытки) прошла
|
||||
AND COALESCE(
|
||||
CASE WHEN t.try_at IS NOT NULL
|
||||
|
|
|
|||
|
|
@ -51,40 +51,44 @@ export const CommsAnalyticsTab = () => {
|
|||
const [statusData, setStatusData] = useState([]);
|
||||
const [attemptData, setAttemptData] = useState([]);
|
||||
const [smsStats, setSmsStats] = useState(null);
|
||||
const [testCallCount, setTestCallCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14;
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - days);
|
||||
const startISO = start.toISOString();
|
||||
// «Всё время» — это действительно всё время: без фильтра по дате (раньше молча бралось 90 дней).
|
||||
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 0 : 14;
|
||||
const startISO = days > 0 ? new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() : null;
|
||||
|
||||
// ── Звонки ──
|
||||
const { data: calls, error: callErr } = await supabase
|
||||
// ── Звонки (тестовые не считаем: клиенту робот не звонил) ──
|
||||
const callQuery = supabase
|
||||
.from("autocall_log")
|
||||
.select("outcome, duration_sec, attempt_no, created_at")
|
||||
.gte("created_at", startISO)
|
||||
.select("outcome, attempt_no, created_at")
|
||||
.eq("was_test_mode", false)
|
||||
.order("created_at", { ascending: true });
|
||||
const { data: calls, error: callErr } = await (startISO ? callQuery.gte("created_at", startISO) : callQuery);
|
||||
if (callErr) throw callErr;
|
||||
|
||||
const total = calls.length;
|
||||
const answered = calls.filter((c) => c.outcome !== "no_answer").length;
|
||||
const confirmed = calls.filter((c) => ["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(c.outcome)).length;
|
||||
const escalated = calls.filter((c) => ["operator", "opt_out", "invalid_number"].includes(c.outcome)).length;
|
||||
const totalSec = calls.reduce((s, c) => s + (c.duration_sec || 0), 0);
|
||||
const avgSec = total > 0 ? Math.round(totalSec / total) : 0;
|
||||
const confirmRate = total > 0 ? Math.round((confirmed / total) * 100) : 0;
|
||||
setCallStats({ total, answered, confirmed, escalated, totalSec, avgSec, confirmRate });
|
||||
const CONFIRM_OUTCOMES = ["confirmed", "confirmed_kept_previous", "confirmed_silent"];
|
||||
const ANSWER_OUTCOMES = [...CONFIRM_OUTCOMES, "no_input_day", "no_input_half"];
|
||||
|
||||
// По дням
|
||||
const total = calls.length;
|
||||
const answered = calls.filter((c) => ANSWER_OUTCOMES.includes(c.outcome)).length;
|
||||
const confirmed = calls.filter((c) => CONFIRM_OUTCOMES.includes(c.outcome)).length;
|
||||
const escalated = calls.filter((c) => ["operator", "opt_out", "invalid_number"].includes(c.outcome)).length;
|
||||
const confirmRate = total > 0 ? Math.round((confirmed / total) * 100) : 0;
|
||||
setCallStats({ total, answered, confirmed, escalated, confirmRate });
|
||||
|
||||
// По дням (МСК — как во всех отчётах)
|
||||
const byDay = {};
|
||||
calls.forEach((c) => {
|
||||
const day = new Date(c.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
const day = new Date(c.created_at).toLocaleDateString("ru-RU", {
|
||||
timeZone: "Europe/Moscow", day: "2-digit", month: "2-digit",
|
||||
});
|
||||
if (!byDay[day]) byDay[day] = { date: day, calls: 0, confirmed: 0 };
|
||||
byDay[day].calls++;
|
||||
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(c.outcome)) byDay[day].confirmed++;
|
||||
if (CONFIRM_OUTCOMES.includes(c.outcome)) byDay[day].confirmed++;
|
||||
});
|
||||
setDailyData(Object.values(byDay));
|
||||
|
||||
|
|
@ -97,25 +101,28 @@ export const CommsAnalyticsTab = () => {
|
|||
color: OUTCOME_COLORS[name] || "#94a3b8",
|
||||
})));
|
||||
|
||||
// По номеру попытки
|
||||
// По номеру попытки (подтверждения — все три исхода, как в воронке)
|
||||
const byAttempt = {};
|
||||
calls.forEach((c) => {
|
||||
const a = c.attempt_no || 1;
|
||||
if (!byAttempt[a]) byAttempt[a] = { attempt: a, total: 0, confirmed: 0 };
|
||||
byAttempt[a].total++;
|
||||
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(c.outcome)) byAttempt[a].confirmed++;
|
||||
if (CONFIRM_OUTCOMES.includes(c.outcome)) byAttempt[a].confirmed++;
|
||||
});
|
||||
setAttemptData(Object.values(byAttempt).sort((a, b) => a.attempt - b.attempt));
|
||||
|
||||
// ── SMS (для сводки каналов) ──
|
||||
const { data: sms, error: smsErr } = await supabase
|
||||
.from("sms_campaign_log")
|
||||
.select("status, created_at")
|
||||
.gte("created_at", startISO);
|
||||
const smsQuery = supabase.from("sms_campaign_log").select("status, created_at");
|
||||
const { data: sms, error: smsErr } = await (startISO ? smsQuery.gte("created_at", startISO) : smsQuery);
|
||||
if (smsErr) throw smsErr;
|
||||
const smsTotal = sms.length;
|
||||
const smsDelivered = sms.filter((s) => s.status === "delivered").length;
|
||||
setSmsStats({ total: smsTotal, delivered: smsDelivered });
|
||||
|
||||
// ── Тестовые звонки за период: в статистику не входят, но показать их число честно ──
|
||||
const testQuery = supabase.from("autocall_log").select("id", { count: "exact", head: true }).eq("was_test_mode", true);
|
||||
const { count: testCount } = await (startISO ? testQuery.gte("created_at", startISO) : testQuery);
|
||||
setTestCallCount(testCount || 0);
|
||||
} catch (e) {
|
||||
console.error("Comms analytics error:", e);
|
||||
} finally {
|
||||
|
|
@ -171,7 +178,7 @@ export const CommsAnalyticsTab = () => {
|
|||
<KpiCard label="SMS доставлено" value={smsStats?.delivered ?? 0} color="#22c55e" />
|
||||
<KpiCard label="Звонков" value={callStats.total} color="var(--color-text)" />
|
||||
<KpiCard label="Дозвонились" value={callStats.answered} color="#3b82f6" />
|
||||
<KpiCard label="Согласовали" value={callStats.confirmed} color="#22c55e" />
|
||||
<KpiCard label="Согласовали (звонки)" value={callStats.confirmed} color="#22c55e" />
|
||||
<KpiCard label="Конверсия звонка" value={`${callStats.confirmRate}%`} color="var(--color-accent)" />
|
||||
</div>
|
||||
|
||||
|
|
@ -224,15 +231,15 @@ export const CommsAnalyticsTab = () => {
|
|||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Минуты ── */}
|
||||
{/* ── Чего в цифрах нет (честно) ── */}
|
||||
<Panel className="p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<KpiCard label="Время на линии (мин)" value={Math.round(callStats.totalSec / 60)} color="var(--color-text)" />
|
||||
<KpiCard label="Средний звонок (сек)" value={callStats.avgSec} color="var(--color-text)" />
|
||||
<KpiCard label="Эскалации менеджеру" value={callStats.escalated} color="#ef4444" />
|
||||
<KpiCard label="Тестовые звонки — не в статистике" value={testCallCount} color="var(--color-text-muted)" />
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
Минуты на линии — для оценки тарифа Miranda (в CDR стоимость не видна).
|
||||
Тестовые звонки идут на номер владельца, поэтому в цифрах выше они не учитываются.
|
||||
Длительность звонков не показываем: АТС пишет её в журнал нулём.
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const STAGE_COLORS = {
|
|||
const CHANNEL_LABELS = {
|
||||
autocall: 'Робот-звонок',
|
||||
manual: 'Менеджер',
|
||||
link: 'Ссылка из SMS',
|
||||
link: 'По ссылке',
|
||||
automation: 'Автоматически',
|
||||
// Клиентские каналы из ссылки (/delivery/{hash}?src=...) — до 29.09.2026 не писались.
|
||||
sms: 'SMS-ссылка',
|
||||
|
|
@ -41,6 +41,7 @@ const STATUS_LABELS = {
|
|||
manual_required: 'В ручной работе',
|
||||
completed: 'Завершено',
|
||||
opt_out: 'Отказ',
|
||||
closed_without_confirmation: 'Без согласования',
|
||||
};
|
||||
|
||||
const KIND_LABELS = { pickup: 'самовывоз', delivery: 'доставка' };
|
||||
|
|
@ -152,6 +153,9 @@ export const CommsFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabe
|
|||
const noAnswer = funnel?.no_answer_list || [];
|
||||
const openedNoConfirm = funnel?.opened_no_confirm || [];
|
||||
const pushSubs = funnel?.push_subs ?? 0;
|
||||
// «Не достали» делится на тех, кто ещё в работе (с ними можно работать),
|
||||
// и тех, чей заказ уже закрыт (выдали/отменили, ответа клиента не было).
|
||||
const silentClosed = funnel?.silent?.closed_count ?? 0;
|
||||
|
||||
if (isLoading || !funnel) {
|
||||
return (
|
||||
|
|
@ -178,7 +182,10 @@ export const CommsFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabe
|
|||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.9rem' }}>
|
||||
<Chip label="Не достали (ни ссылка, ни звонок)" value={`${silent.count} · ${silent.pct}%`} color="#ef4444" fontSize={fontSize} />
|
||||
<Chip label="Не достали, заказ ещё в работе" value={`${silent.count} · ${silent.pct}%`} color="#ef4444" fontSize={fontSize} />
|
||||
{silentClosed > 0 && (
|
||||
<Chip label="Не достали, заказ уже закрыт" value={silentClosed} color="#94a3b8" fontSize={fontSize} />
|
||||
)}
|
||||
{stages.find((s) => s.key === 'push') && (
|
||||
<Chip
|
||||
label={stages.find((s) => s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe("CommsFunnelBlock", () => {
|
|||
});
|
||||
|
||||
it("показывает, сколько не достали и что push не используется", () => {
|
||||
expect(html).toContain("Не достали (ни ссылка, ни звонок)");
|
||||
expect(html).toContain("Не достали, заказ ещё в работе");
|
||||
expect(html).toContain("265 · 66.9%");
|
||||
expect(html).toContain("Push не используется, подписок");
|
||||
});
|
||||
|
|
@ -77,7 +77,23 @@ describe("CommsFunnelBlock", () => {
|
|||
it("показывает каналы подтверждения человеческими подписями", () => {
|
||||
expect(html).toContain("Робот-звонок");
|
||||
expect(html).toContain("Менеджер");
|
||||
expect(html).toContain("Ссылка из SMS");
|
||||
expect(html).toContain("По ссылке");
|
||||
});
|
||||
|
||||
it("делит «не достали» на тех, кто в работе, и тех, чей заказ закрыт", () => {
|
||||
const withSilent = renderToStaticMarkup(
|
||||
<CommsFunnelBlock
|
||||
funnel={{ ...funnel, silent: { count: 25, pct: 5.6, closed_count: 293, unreached_total: 318 } }}
|
||||
mobile={false}
|
||||
periodLabel="30 дней"
|
||||
/>,
|
||||
);
|
||||
expect(withSilent).toContain("Не достали, заказ ещё в работе");
|
||||
expect(withSilent).toContain("25 · 5.6%");
|
||||
expect(withSilent).toContain("Не достали, заказ уже закрыт");
|
||||
expect(withSilent).toContain("293");
|
||||
// без closed_count второй чип не рисуем (старые ответы API)
|
||||
expect(html).not.toContain("Не достали, заказ уже закрыт");
|
||||
});
|
||||
|
||||
it("подписывает клиентские каналы ссылки, а не показывает ключи БД", () => {
|
||||
|
|
|
|||
|
|
@ -178,9 +178,10 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period
|
|||
fontSize: fontSize.s, color: 'var(--color-text)',
|
||||
}}>
|
||||
<strong>Мимо цепочки (без даты)</strong>: выдано {bypass.delivered ?? 0} · платное хранение {bypass.paid_storage ?? 0} ·
|
||||
{' '}отменено {bypass.cancelled ?? 0}
|
||||
{' '}отменено {bypass.cancelled ?? 0} · без согласования {bypass.no_confirmation ?? 0}
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, marginTop: '0.15rem' }}>
|
||||
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает.
|
||||
«Без согласования» — заказы, закрытые (в т.ч. обменом из 1С) без выбранной даты и без ответа клиента.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const funnel = {
|
|||
{ key: "dated", label: "Дата согласована", value: 161, pct: 40.7 },
|
||||
{ key: "done", label: "Выдано по цепочке", value: 134, pct: 33.8 },
|
||||
],
|
||||
bypass: { delivered: 199, paid_storage: 3, cancelled: 0 },
|
||||
bypass: { delivered: 199, paid_storage: 3, cancelled: 0, no_confirmation: 42 },
|
||||
split: { pickup: 235, delivery: 161 },
|
||||
channels: [
|
||||
{ channel: "manual", count: 135 },
|
||||
|
|
@ -54,6 +54,7 @@ describe("OrderPathFunnelBlock", () => {
|
|||
expect(html).toContain("выдано 199");
|
||||
expect(html).toContain("платное хранение 3");
|
||||
expect(html).toContain("воронка выше их не считает");
|
||||
expect(html).toContain("без согласования 42");
|
||||
});
|
||||
|
||||
it("показывает источники даты и разбивку по типу заказа", () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const STATUS_LABELS = {
|
|||
first_sms_sending: "Отправка 1-го SMS",
|
||||
sms_sending: "Отправка SMS",
|
||||
manual_required: "Требуется ручное согласование",
|
||||
closed_without_confirmation: "Без согласования",
|
||||
manual_confirmation_required: "Требуется ручное согласование",
|
||||
ready_to_launch: "Готов к отправке",
|
||||
ready_for_notification: "Готов к отправке",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const NOTIF_LABELS = {
|
|||
send_failed: "Ошибка отправки",
|
||||
manual_required: "Требуется ручное управление",
|
||||
paid_storage_sending: "Отправляется…",
|
||||
closed_without_confirmation: "Без согласования",
|
||||
paid_storage_sent: "Платное хранение: отправлено",
|
||||
draft: "Черновик",
|
||||
confirmed: "Клиент согласовал дату",
|
||||
|
|
@ -35,6 +36,7 @@ const NOTIF_TONES = {
|
|||
second_sms_sent: "accent",
|
||||
send_failed: "danger",
|
||||
manual_required: "warning",
|
||||
closed_without_confirmation: "neutral",
|
||||
paid_storage_sending: "info",
|
||||
paid_storage_sent: "accent",
|
||||
draft: "neutral",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export const NOTIFICATION_STATUS_LABELS = {
|
|||
send_failed: "Ошибка отправки",
|
||||
confirmed: "Согласовано",
|
||||
manual_required: "Требуется ручное подтверждение",
|
||||
// Заказ закрыли без выбранной даты и без ответа клиента (в т.ч. выдача, закрытая из 1С).
|
||||
// Ставить «Согласовано» здесь нельзя: подтверждения не было.
|
||||
closed_without_confirmation: "Без согласования",
|
||||
};
|
||||
|
||||
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
-- admin_comms_funnel — воронка коммуникаций для страницы «Аналитика» (mega_admin/admin).
|
||||
-- Один вызов отдаёт всё, что нужно блоку «Воронка коммуникаций»:
|
||||
-- stages — этапы цепочки по когорте групп за период (push → SMS → звонки → согласование → ручная → выдача);
|
||||
-- Один вызов отдаёт всё, что нужно блокам «Воронка коммуникаций» и «Путь заказа»:
|
||||
-- stages — этапы цепочки по когорте групп за период (push → SMS → ссылка → звонки → согласование → ручная → выдача);
|
||||
-- attempts — с какой попытки берут трубку и согласовывают;
|
||||
-- channels — чем подтвердили (робот / менеджер / ссылка);
|
||||
-- channels — КТО подтвердил (робот / логист / ссылка / push): считается по событию, а не по текущему статусу;
|
||||
-- lists — «не поднимают трубку» и «открыли ссылку, но не подтвердили» (по 10 записей, для работы менеджера);
|
||||
-- silent — сколько не достали ни ссылкой, ни звонком.
|
||||
-- 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,
|
||||
|
|
@ -27,17 +40,47 @@ with cohort as (
|
|||
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
|
||||
),
|
||||
-- группы, которых не достали ни ссылкой, ни ответом на звонок
|
||||
silent as (
|
||||
select c.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 inv i where i.order_group_id = c.id and i.opened_at is not null)
|
||||
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'))
|
||||
|
|
@ -48,15 +91,18 @@ stages as (
|
|||
(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(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 cohort c where c.notification_status = 'confirmed') as confirmed_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 silent) as silent_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
|
||||
|
|
@ -67,7 +113,7 @@ pct as (
|
|||
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
|
||||
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 (
|
||||
|
|
@ -81,56 +127,24 @@ path as (
|
|||
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
|
||||
),
|
||||
path_channels as (
|
||||
select
|
||||
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 delivery_invitations di
|
||||
where di.order_group_id = c.id and di.confirmed_at is not null) then 'sms'
|
||||
when c.manual_confirmation_at is not null then 'manual'
|
||||
when c.called then 'call'
|
||||
else 'unknown'
|
||||
end as channel,
|
||||
count(*) as cnt
|
||||
from cohort c
|
||||
where c.delivery_date is not null or c.pickup_date is not null
|
||||
group by 1
|
||||
),
|
||||
path_sources as (
|
||||
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
|
||||
),
|
||||
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 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
|
||||
),
|
||||
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,
|
||||
|
|
@ -143,10 +157,13 @@ no_answer_rows as (
|
|||
),
|
||||
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
|
||||
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.opened_at is not null and i.confirmed_at is null
|
||||
order by i.opened_at desc
|
||||
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(
|
||||
|
|
@ -162,7 +179,12 @@ select jsonb_build_object(
|
|||
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),
|
||||
'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),
|
||||
|
|
@ -172,8 +194,15 @@ select jsonb_build_object(
|
|||
'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),
|
||||
-- Кто подтвердил за период (не зависит от текущего статуса заказа)
|
||||
'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),
|
||||
|
|
@ -191,17 +220,25 @@ select jsonb_build_object(
|
|||
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),
|
||||
'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 path_sources ps), '[]'::jsonb),
|
||||
-- Каким каналом согласовали дату: push / sms / звонок робота / вручную (логист).
|
||||
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 path_channels pc), '[]'::jsonb)
|
||||
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 → ссылка → звонки → согласование → ручная работа → выдача.';
|
||||
'Воронка коммуникаций для аналитики: этапы push → SMS → ссылка → звонки → согласование → ручная работа → выдача. Тестовые звонки исключены, канал согласования считается по событию.';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
-- 20261002_confirmation_honesty.sql
|
||||
--
|
||||
-- 1. Закрытие заказа без согласования больше не записывается как «Согласовано».
|
||||
-- Триггер clear_manual_required_on_action ставил notification_status='confirmed'
|
||||
-- на любой уход заказа с ручной работы — включая доставку, закрытую в 1С без даты
|
||||
-- и без ответа клиента. Из-за этого:
|
||||
-- • в реестре и карточке такие заказы читались как «Согласовано»;
|
||||
-- • в аналитике стадия «Согласовано» (~42% групп) и блок «Чем подтвердили»
|
||||
-- показывали эти заказы, причём канал подставлялся как «Ссылка из SMS».
|
||||
-- Теперь «Согласовано» ставится только когда у заказа есть дата (её поставил логист
|
||||
-- или выбрал клиент), иначе — новый статус 'closed_without_confirmation' («Без согласования»).
|
||||
-- 2. Разово переводим уже испорченные строки (только те, где нет ни источника даты,
|
||||
-- ни даты, ни подтверждённого приглашения — то есть подтверждения не было).
|
||||
--
|
||||
-- Применять от supabase_admin (владелец таблицы и функций):
|
||||
-- docker cp 20261002_confirmation_honesty.sql supabase-db:/tmp/ && \
|
||||
-- docker exec supabase-db psql -U supabase_admin -d postgres -f /tmp/20261002_confirmation_honesty.sql
|
||||
--
|
||||
-- ⚠️ На order_groups висит http-триггер "Delivered" (AFTER UPDATE, n8n-вебхук) — он
|
||||
-- срабатывает на ЛЮБОЕ обновление. На время backfill он выключается в той же
|
||||
-- транзакции (ACCESS EXCLUSIVE), чтобы не слать в n8n 128 событий «доставлено»
|
||||
-- по заказам, которые на самом деле не менялись.
|
||||
|
||||
begin;
|
||||
|
||||
-- ── 1. Разрешаем новый статус ───────────────────────────────────────────────
|
||||
alter table public.order_groups drop constraint if exists order_groups_notification_status_check;
|
||||
alter table public.order_groups add constraint order_groups_notification_status_check
|
||||
check (notification_status = any (array[
|
||||
'link_ready', 'sms_sending', 'first_sms_sent', 'second_sms_sending', 'second_sms_sent',
|
||||
'send_failed', 'manual_required', 'paid_storage_sending', 'paid_storage_sent',
|
||||
'draft', 'not_started', 'confirmed', 'address_required', 'completed',
|
||||
'closed_without_confirmation'
|
||||
]::text[]));
|
||||
|
||||
-- ── 2. Триггер больше не подменяет «закрыт» на «согласовано» ────────────────
|
||||
create or replace function public.clear_manual_required_on_action()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
if old.delivery_status in ('pending_confirmation', 'manual_confirmation_required')
|
||||
and new.delivery_status not in ('pending_confirmation', 'manual_confirmation_required')
|
||||
and old.notification_status = 'manual_required' then
|
||||
-- Дата есть → согласование действительно было (логист проставил дату или клиент выбрал слот).
|
||||
-- Даты нет → заказ закрыли без согласования: «Согласовано» писать нельзя.
|
||||
if coalesce(new.delivery_date, new.pickup_date) is not null then
|
||||
new.notification_status := 'confirmed';
|
||||
else
|
||||
new.notification_status := 'closed_without_confirmation';
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── 3. Backfill: строки, где «Согласовано» не подтверждено ничем ────────────
|
||||
alter table public.order_groups disable trigger "Delivered";
|
||||
|
||||
update public.order_groups og
|
||||
set notification_status = 'closed_without_confirmation'
|
||||
where og.notification_status = 'confirmed'
|
||||
and og.delivery_date_source is null
|
||||
and og.delivery_date is null
|
||||
and og.pickup_date is null
|
||||
and og.delivery_status not in ('pending_confirmation', 'manual_confirmation_required');
|
||||
|
||||
alter table public.order_groups enable trigger "Delivered";
|
||||
|
||||
commit;
|
||||
|
||||
-- Контроль после применения:
|
||||
-- select notification_status, count(*) from order_groups group by 1 order by 2 desc;
|
||||
-- select count(*) from order_groups where notification_status='closed_without_confirmation'; -- ожидали 128
|
||||
Loading…
Reference in New Issue