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