From bf2b3559ae3155eefabf83ea5f772f6c16286b64 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 00:34:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(analytics):=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=86=D0=B8=D1=84=D1=80=D1=8B=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=E2=80=94=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BE=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B8,=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D1=8F=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Триггер закрытия без даты больше не пишет «Согласовано» (новый статус closed_without_confirmation + backfill 128 строк). Робот/логист/ссылка считаются по событию одним классификатором (блоки больше не расходятся), тестовые звонки исключены, «Открыли ссылку» — по access_count, «не достали» разделено на «в работе» и «закрыто». --- docs/analytics-check-2026-10-02.md | 80 +++++++++ public/service-worker.js | 2 +- scripts/autocall_scheduler.py | 2 +- .../admin/analytics/CommsAnalyticsTab.jsx | 67 ++++---- .../admin/analytics/CommsFunnelBlock.jsx | 11 +- .../admin/analytics/CommsFunnelBlock.test.jsx | 20 ++- .../admin/analytics/OrderPathFunnelBlock.jsx | 3 +- .../analytics/OrderPathFunnelBlock.test.jsx | 3 +- .../orders/OrderHistoryTimeline.jsx | 1 + src/components/orders/SmsStatusCard.jsx | 2 + src/services/orderGroupViews.js | 3 + supabase/admin_comms_funnel.sql | 159 +++++++++++------- .../20261002_confirmation_honesty.sql | 76 +++++++++ 13 files changed, 330 insertions(+), 99 deletions(-) create mode 100644 docs/analytics-check-2026-10-02.md create mode 100644 supabase/migrations/20261002_confirmation_honesty.sql diff --git a/docs/analytics-check-2026-10-02.md b/docs/analytics-check-2026-10-02.md new file mode 100644 index 0000000..b3b9691 --- /dev/null +++ b/docs/analytics-check-2026-10-02.md @@ -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`). diff --git a/public/service-worker.js b/public/service-worker.js index 8b8308b..0a83c5b 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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"]; diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index a387836..f452208 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -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 diff --git a/src/components/admin/analytics/CommsAnalyticsTab.jsx b/src/components/admin/analytics/CommsAnalyticsTab.jsx index d76eb9f..58a5fb1 100644 --- a/src/components/admin/analytics/CommsAnalyticsTab.jsx +++ b/src/components/admin/analytics/CommsAnalyticsTab.jsx @@ -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 = () => { - + @@ -224,15 +231,15 @@ export const CommsAnalyticsTab = () => { )} - {/* ── Минуты ── */} + {/* ── Чего в цифрах нет (честно) ── */}
- - +
- Минуты на линии — для оценки тарифа Miranda (в CDR стоимость не видна). + Тестовые звонки идут на номер владельца, поэтому в цифрах выше они не учитываются. + Длительность звонков не показываем: АТС пишет её в журнал нулём.
diff --git a/src/components/admin/analytics/CommsFunnelBlock.jsx b/src/components/admin/analytics/CommsFunnelBlock.jsx index 7caca68..13bf967 100644 --- a/src/components/admin/analytics/CommsFunnelBlock.jsx +++ b/src/components/admin/analytics/CommsFunnelBlock.jsx @@ -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
- + + {silentClosed > 0 && ( + + )} {stages.find((s) => s.key === 'push') && ( s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'} diff --git a/src/components/admin/analytics/CommsFunnelBlock.test.jsx b/src/components/admin/analytics/CommsFunnelBlock.test.jsx index 813411b..dba7b35 100644 --- a/src/components/admin/analytics/CommsFunnelBlock.test.jsx +++ b/src/components/admin/analytics/CommsFunnelBlock.test.jsx @@ -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( + , + ); + expect(withSilent).toContain("Не достали, заказ ещё в работе"); + expect(withSilent).toContain("25 · 5.6%"); + expect(withSilent).toContain("Не достали, заказ уже закрыт"); + expect(withSilent).toContain("293"); + // без closed_count второй чип не рисуем (старые ответы API) + expect(html).not.toContain("Не достали, заказ уже закрыт"); }); it("подписывает клиентские каналы ссылки, а не показывает ключи БД", () => { diff --git a/src/components/admin/analytics/OrderPathFunnelBlock.jsx b/src/components/admin/analytics/OrderPathFunnelBlock.jsx index b4d1c41..e826937 100644 --- a/src/components/admin/analytics/OrderPathFunnelBlock.jsx +++ b/src/components/admin/analytics/OrderPathFunnelBlock.jsx @@ -178,9 +178,10 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period fontSize: fontSize.s, color: 'var(--color-text)', }}> Мимо цепочки (без даты): выдано {bypass.delivered ?? 0} · платное хранение {bypass.paid_storage ?? 0} · - {' '}отменено {bypass.cancelled ?? 0} + {' '}отменено {bypass.cancelled ?? 0} · без согласования {bypass.no_confirmation ?? 0}
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает. + «Без согласования» — заказы, закрытые (в т.ч. обменом из 1С) без выбранной даты и без ответа клиента.
diff --git a/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx b/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx index f1b572f..4f7a91c 100644 --- a/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx +++ b/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx @@ -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("показывает источники даты и разбивку по типу заказа", () => { diff --git a/src/components/orders/OrderHistoryTimeline.jsx b/src/components/orders/OrderHistoryTimeline.jsx index b832bc2..201d821 100644 --- a/src/components/orders/OrderHistoryTimeline.jsx +++ b/src/components/orders/OrderHistoryTimeline.jsx @@ -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: "Готов к отправке", diff --git a/src/components/orders/SmsStatusCard.jsx b/src/components/orders/SmsStatusCard.jsx index 9488fca..eedb1e3 100644 --- a/src/components/orders/SmsStatusCard.jsx +++ b/src/components/orders/SmsStatusCard.jsx @@ -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", diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index 8e21579..f09b53d 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -46,6 +46,9 @@ export const NOTIFICATION_STATUS_LABELS = { send_failed: "Ошибка отправки", confirmed: "Согласовано", manual_required: "Требуется ручное подтверждение", + // Заказ закрыли без выбранной даты и без ответа клиента (в т.ч. выдача, закрытая из 1С). + // Ставить «Согласовано» здесь нельзя: подтверждения не было. + closed_without_confirmation: "Без согласования", }; export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ diff --git a/supabase/admin_comms_funnel.sql b/supabase/admin_comms_funnel.sql index c885975..a498043 100644 --- a/supabase/admin_comms_funnel.sql +++ b/supabase/admin_comms_funnel.sql @@ -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 → ссылка → звонки → согласование → ручная работа → выдача. Тестовые звонки исключены, канал согласования считается по событию.'; diff --git a/supabase/migrations/20261002_confirmation_honesty.sql b/supabase/migrations/20261002_confirmation_honesty.sql new file mode 100644 index 0000000..790feb8 --- /dev/null +++ b/supabase/migrations/20261002_confirmation_honesty.sql @@ -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