fix(analytics): честные цифры согласований — без согласования, тестовые звонки, открытия ссылки, единый канал

Триггер закрытия без даты больше не пишет «Согласовано» (новый статус closed_without_confirmation + backfill 128 строк). Робот/логист/ссылка считаются по событию одним классификатором (блоки больше не расходятся), тестовые звонки исключены, «Открыли ссылку» — по access_count, «не достали» разделено на «в работе» и «закрыто».
This commit is contained in:
Hermes 2026-10-03 00:34:57 +02:00
parent 6dba84f22a
commit bf2b3559ae
13 changed files with 330 additions and 99 deletions

View File

@ -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`).

View File

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

View File

@ -129,7 +129,7 @@ GROUPS_TO_CALL_SQL = """
) t ON true ) t ON true
WHERE og.delivery_status = 'pending_confirmation' WHERE og.delivery_status = 'pending_confirmation'
AND og.autocall_opt_out = false 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 (или после неудачной попытки) прошла -- Пауза после SMS (или после неудачной попытки) прошла
AND COALESCE( AND COALESCE(
CASE WHEN t.try_at IS NOT NULL CASE WHEN t.try_at IS NOT NULL

View File

@ -51,40 +51,44 @@ export const CommsAnalyticsTab = () => {
const [statusData, setStatusData] = useState([]); const [statusData, setStatusData] = useState([]);
const [attemptData, setAttemptData] = useState([]); const [attemptData, setAttemptData] = useState([]);
const [smsStats, setSmsStats] = useState(null); const [smsStats, setSmsStats] = useState(null);
const [testCallCount, setTestCallCount] = useState(0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14; // «Всё время» — это действительно всё время: без фильтра по дате (раньше молча бралось 90 дней).
const start = new Date(); const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 0 : 14;
start.setDate(start.getDate() - days); const startISO = days > 0 ? new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() : null;
const startISO = start.toISOString();
// ── Звонки ── // ── Звонки (тестовые не считаем: клиенту робот не звонил) ──
const { data: calls, error: callErr } = await supabase const callQuery = supabase
.from("autocall_log") .from("autocall_log")
.select("outcome, duration_sec, attempt_no, created_at") .select("outcome, attempt_no, created_at")
.gte("created_at", startISO) .eq("was_test_mode", false)
.order("created_at", { ascending: true }); .order("created_at", { ascending: true });
const { data: calls, error: callErr } = await (startISO ? callQuery.gte("created_at", startISO) : callQuery);
if (callErr) throw callErr; if (callErr) throw callErr;
const total = calls.length; const CONFIRM_OUTCOMES = ["confirmed", "confirmed_kept_previous", "confirmed_silent"];
const answered = calls.filter((c) => c.outcome !== "no_answer").length; const ANSWER_OUTCOMES = [...CONFIRM_OUTCOMES, "no_input_day", "no_input_half"];
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 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 = {}; const byDay = {};
calls.forEach((c) => { 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 }; if (!byDay[day]) byDay[day] = { date: day, calls: 0, confirmed: 0 };
byDay[day].calls++; 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)); setDailyData(Object.values(byDay));
@ -97,25 +101,28 @@ export const CommsAnalyticsTab = () => {
color: OUTCOME_COLORS[name] || "#94a3b8", color: OUTCOME_COLORS[name] || "#94a3b8",
}))); })));
// По номеру попытки // По номеру попытки (подтверждения — все три исхода, как в воронке)
const byAttempt = {}; const byAttempt = {};
calls.forEach((c) => { calls.forEach((c) => {
const a = c.attempt_no || 1; const a = c.attempt_no || 1;
if (!byAttempt[a]) byAttempt[a] = { attempt: a, total: 0, confirmed: 0 }; if (!byAttempt[a]) byAttempt[a] = { attempt: a, total: 0, confirmed: 0 };
byAttempt[a].total++; 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)); setAttemptData(Object.values(byAttempt).sort((a, b) => a.attempt - b.attempt));
// ── SMS (для сводки каналов) ── // ── SMS (для сводки каналов) ──
const { data: sms, error: smsErr } = await supabase const smsQuery = supabase.from("sms_campaign_log").select("status, created_at");
.from("sms_campaign_log") const { data: sms, error: smsErr } = await (startISO ? smsQuery.gte("created_at", startISO) : smsQuery);
.select("status, created_at")
.gte("created_at", startISO);
if (smsErr) throw smsErr; if (smsErr) throw smsErr;
const smsTotal = sms.length; const smsTotal = sms.length;
const smsDelivered = sms.filter((s) => s.status === "delivered").length; const smsDelivered = sms.filter((s) => s.status === "delivered").length;
setSmsStats({ total: smsTotal, delivered: smsDelivered }); 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) { } catch (e) {
console.error("Comms analytics error:", e); console.error("Comms analytics error:", e);
} finally { } finally {
@ -171,7 +178,7 @@ export const CommsAnalyticsTab = () => {
<KpiCard label="SMS доставлено" value={smsStats?.delivered ?? 0} color="#22c55e" /> <KpiCard label="SMS доставлено" value={smsStats?.delivered ?? 0} color="#22c55e" />
<KpiCard label="Звонков" value={callStats.total} color="var(--color-text)" /> <KpiCard label="Звонков" value={callStats.total} color="var(--color-text)" />
<KpiCard label="Дозвонились" value={callStats.answered} color="#3b82f6" /> <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)" /> <KpiCard label="Конверсия звонка" value={`${callStats.confirmRate}%`} color="var(--color-accent)" />
</div> </div>
@ -224,15 +231,15 @@ export const CommsAnalyticsTab = () => {
</Panel> </Panel>
)} )}
{/* ── Минуты ── */} {/* ── Чего в цифрах нет (честно) ── */}
<Panel className="p-4"> <Panel className="p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <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={callStats.escalated} color="#ef4444" />
<KpiCard label="Тестовые звонки — не в статистике" value={testCallCount} color="var(--color-text-muted)" />
</div> </div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Минуты на линии — для оценки тарифа Miranda (в CDR стоимость не видна). Тестовые звонки идут на номер владельца, поэтому в цифрах выше они не учитываются.
Длительность звонков не показываем: АТС пишет её в журнал нулём.
</div> </div>
</Panel> </Panel>
</div> </div>

View File

@ -24,7 +24,7 @@ const STAGE_COLORS = {
const CHANNEL_LABELS = { const CHANNEL_LABELS = {
autocall: 'Робот-звонок', autocall: 'Робот-звонок',
manual: 'Менеджер', manual: 'Менеджер',
link: 'Ссылка из SMS', link: 'По ссылке',
automation: 'Автоматически', automation: 'Автоматически',
// Клиентские каналы из ссылки (/delivery/{hash}?src=...) — до 29.09.2026 не писались. // Клиентские каналы из ссылки (/delivery/{hash}?src=...) — до 29.09.2026 не писались.
sms: 'SMS-ссылка', sms: 'SMS-ссылка',
@ -41,6 +41,7 @@ const STATUS_LABELS = {
manual_required: 'В ручной работе', manual_required: 'В ручной работе',
completed: 'Завершено', completed: 'Завершено',
opt_out: 'Отказ', opt_out: 'Отказ',
closed_without_confirmation: 'Без согласования',
}; };
const KIND_LABELS = { pickup: 'самовывоз', delivery: 'доставка' }; const KIND_LABELS = { pickup: 'самовывоз', delivery: 'доставка' };
@ -152,6 +153,9 @@ export const CommsFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabe
const noAnswer = funnel?.no_answer_list || []; const noAnswer = funnel?.no_answer_list || [];
const openedNoConfirm = funnel?.opened_no_confirm || []; const openedNoConfirm = funnel?.opened_no_confirm || [];
const pushSubs = funnel?.push_subs ?? 0; const pushSubs = funnel?.push_subs ?? 0;
// «Не достали» делится на тех, кто ещё в работе (с ними можно работать),
// и тех, чей заказ уже закрыт (выдали/отменили, ответа клиента не было).
const silentClosed = funnel?.silent?.closed_count ?? 0;
if (isLoading || !funnel) { if (isLoading || !funnel) {
return ( return (
@ -178,7 +182,10 @@ export const CommsFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabe
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.9rem' }}> <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') && ( {stages.find((s) => s.key === 'push') && (
<Chip <Chip
label={stages.find((s) => s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'} label={stages.find((s) => s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'}

View File

@ -62,7 +62,7 @@ describe("CommsFunnelBlock", () => {
}); });
it("показывает, сколько не достали и что push не используется", () => { it("показывает, сколько не достали и что push не используется", () => {
expect(html).toContain("Не достали (ни ссылка, ни звонок)"); expect(html).toContain("Не достали, заказ ещё в работе");
expect(html).toContain("265 · 66.9%"); expect(html).toContain("265 · 66.9%");
expect(html).toContain("Push не используется, подписок"); expect(html).toContain("Push не используется, подписок");
}); });
@ -77,7 +77,23 @@ describe("CommsFunnelBlock", () => {
it("показывает каналы подтверждения человеческими подписями", () => { it("показывает каналы подтверждения человеческими подписями", () => {
expect(html).toContain("Робот-звонок"); expect(html).toContain("Робот-звонок");
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("подписывает клиентские каналы ссылки, а не показывает ключи БД", () => { it("подписывает клиентские каналы ссылки, а не показывает ключи БД", () => {

View File

@ -178,9 +178,10 @@ export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, period
fontSize: fontSize.s, color: 'var(--color-text)', fontSize: fontSize.s, color: 'var(--color-text)',
}}> }}>
<strong>Мимо цепочки (без даты)</strong>: выдано {bypass.delivered ?? 0} · платное хранение {bypass.paid_storage ?? 0} · <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' }}> <div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, marginTop: '0.15rem' }}>
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает. Эти заказы довели до выдачи без согласования даты — воронка выше их не считает.
«Без согласования» — заказы, закрытые (в т.ч. обменом из 1С) без выбранной даты и без ответа клиента.
</div> </div>
</div> </div>

View File

@ -12,7 +12,7 @@ const funnel = {
{ key: "dated", label: "Дата согласована", value: 161, pct: 40.7 }, { key: "dated", label: "Дата согласована", value: 161, pct: 40.7 },
{ key: "done", label: "Выдано по цепочке", value: 134, pct: 33.8 }, { 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 }, split: { pickup: 235, delivery: 161 },
channels: [ channels: [
{ channel: "manual", count: 135 }, { channel: "manual", count: 135 },
@ -54,6 +54,7 @@ describe("OrderPathFunnelBlock", () => {
expect(html).toContain("выдано 199"); expect(html).toContain("выдано 199");
expect(html).toContain("платное хранение 3"); expect(html).toContain("платное хранение 3");
expect(html).toContain("воронка выше их не считает"); expect(html).toContain("воронка выше их не считает");
expect(html).toContain("без согласования 42");
}); });
it("показывает источники даты и разбивку по типу заказа", () => { it("показывает источники даты и разбивку по типу заказа", () => {

View File

@ -12,6 +12,7 @@ const STATUS_LABELS = {
first_sms_sending: "Отправка 1-го SMS", first_sms_sending: "Отправка 1-го SMS",
sms_sending: "Отправка SMS", sms_sending: "Отправка SMS",
manual_required: "Требуется ручное согласование", manual_required: "Требуется ручное согласование",
closed_without_confirmation: "Без согласования",
manual_confirmation_required: "Требуется ручное согласование", manual_confirmation_required: "Требуется ручное согласование",
ready_to_launch: "Готов к отправке", ready_to_launch: "Готов к отправке",
ready_for_notification: "Готов к отправке", ready_for_notification: "Готов к отправке",

View File

@ -20,6 +20,7 @@ const NOTIF_LABELS = {
send_failed: "Ошибка отправки", send_failed: "Ошибка отправки",
manual_required: "Требуется ручное управление", manual_required: "Требуется ручное управление",
paid_storage_sending: "Отправляется…", paid_storage_sending: "Отправляется…",
closed_without_confirmation: "Без согласования",
paid_storage_sent: "Платное хранение: отправлено", paid_storage_sent: "Платное хранение: отправлено",
draft: "Черновик", draft: "Черновик",
confirmed: "Клиент согласовал дату", confirmed: "Клиент согласовал дату",
@ -35,6 +36,7 @@ const NOTIF_TONES = {
second_sms_sent: "accent", second_sms_sent: "accent",
send_failed: "danger", send_failed: "danger",
manual_required: "warning", manual_required: "warning",
closed_without_confirmation: "neutral",
paid_storage_sending: "info", paid_storage_sending: "info",
paid_storage_sent: "accent", paid_storage_sent: "accent",
draft: "neutral", draft: "neutral",

View File

@ -46,6 +46,9 @@ export const NOTIFICATION_STATUS_LABELS = {
send_failed: "Ошибка отправки", send_failed: "Ошибка отправки",
confirmed: "Согласовано", confirmed: "Согласовано",
manual_required: "Требуется ручное подтверждение", manual_required: "Требуется ручное подтверждение",
// Заказ закрыли без выбранной даты и без ответа клиента (в т.ч. выдача, закрытая из 1С).
// Ставить «Согласовано» здесь нельзя: подтверждения не было.
closed_without_confirmation: "Без согласования",
}; };
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [

View File

@ -1,12 +1,25 @@
-- admin_comms_funnel — воронка коммуникаций для страницы «Аналитика» (mega_admin/admin). -- admin_comms_funnel — воронка коммуникаций для страницы «Аналитика» (mega_admin/admin).
-- Один вызов отдаёт всё, что нужно блоку «Воронка коммуникаций»: -- Один вызов отдаёт всё, что нужно блокам «Воронка коммуникаций» и «Путь заказа»:
-- stages — этапы цепочки по когорте групп за период (push → SMS → звонки → согласование → ручная → выдача); -- stages — этапы цепочки по когорте групп за период (push → SMS → ссылка → звонки → согласование → ручная → выдача);
-- attempts — с какой попытки берут трубку и согласовывают; -- attempts — с какой попытки берут трубку и согласовывают;
-- channels — чем подтвердили (робот / менеджер / ссылка); -- channels — КТО подтвердил (робот / логист / ссылка / push): считается по событию, а не по текущему статусу;
-- lists — «не поднимают трубку» и «открыли ссылку, но не подтвердили» (по 10 записей, для работы менеджера); -- lists — «не поднимают трубку» и «открыли ссылку, но не подтвердили» (по 10 записей, для работы менеджера);
-- silent — сколько не достали ни ссылкой, ни звонком. -- silent — кого не достали ни ссылкой, ни звонком (отдельно: ещё в работе и уже закрытые).
-- Конвенция периода — как у admin_automation_economics: p_days=30 → группы, созданные за 30 дней; -- Конвенция периода — как у admin_automation_economics: p_days=30 → группы, созданные за 30 дней;
-- p_days=0 → всё время; p_date_from/p_date_to — явное окно (перекрывает p_days). -- 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( create or replace function public.admin_comms_funnel(
p_days integer default 30, p_days integer default 30,
p_date_from timestamp with time zone default null, 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()) and og.created_at <= coalesce(p_date_to, now())
), ),
calls as ( calls as (
-- только боевые звонки: тестовые набирают номер владельца и к клиенту отношения не имеют
select l.* from autocall_log l join cohort c on c.id = l.order_group_id 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 ( inv as (
select i.* from delivery_invitations i join cohort c on c.id = i.order_group_id select i.* from delivery_invitations i join cohort c on c.id = i.order_group_id
), ),
-- группы, которых не достали ни ссылкой, ни ответом на звонок -- ссылка реально открыта (клиент заходил на страницу выбора слота)
silent as ( opened_inv as (
select c.id 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 from cohort c
where c.notification_status not in ('confirmed', 'opt_out') 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 and not exists (select 1 from calls l
where l.order_group_id = c.id where l.order_group_id = c.id
and l.outcome in ('confirmed', 'no_input_day', 'no_input_half')) 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 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.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 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)) as called_cnt,
(select count(*) from cohort c where exists ( (select count(*) from cohort c where exists (
select 1 from calls l where l.order_group_id = c.id 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, 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.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 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 ( pct as (
select 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(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(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(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 from stages
), ),
path as ( path as (
@ -81,56 +127,24 @@ path as (
and delivery_date is null and pickup_date is null) as bypass_done, 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 = 'paid_storage') as paid_storage,
count(*) filter (where delivery_status = 'cancelled') as cancelled, 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 = 'pickup') as pickup_cnt,
count(*) filter (where delivery_type = 'delivery') as delivery_cnt count(*) filter (where delivery_type = 'delivery') as delivery_cnt
from cohort 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 ( attempt_rows as (
select attempt_no, select attempt_no,
count(*) as total, count(*) as total,
count(*) filter (where outcome <> 'no_answer') as answered, count(*) filter (where outcome in ('confirmed', 'confirmed_silent', 'confirmed_kept_previous',
count(*) filter (where outcome = 'confirmed') as confirmed, '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 count(*) filter (where outcome = 'no_answer') as no_answer
from calls from calls
group by attempt_no group by attempt_no
order 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 ( no_answer_rows as (
select c.customer_phone as phone, coalesce(nullif(c.town, ''), '—') as town, select c.customer_phone as phone, coalesce(nullif(c.town, ''), '—') as town,
c.delivery_type as kind, count(*) as attempts, c.delivery_type as kind, count(*) as attempts,
@ -143,10 +157,13 @@ no_answer_rows as (
), ),
opened_rows as ( opened_rows as (
select i.order_number as num, coalesce(nullif(i.customer_name, ''), '—') as name, 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 from inv i
where i.opened_at is not null and i.confirmed_at is null where i.confirmed_at is null
order by i.opened_at desc 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 limit 10
) )
select jsonb_build_object( 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', '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) 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), 'push_subs', (select count(*) from push_subscriptions),
'second_sms', jsonb_build_object('count', s.sms2_cnt, '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), '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, '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 '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), ) 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( 'no_answer_list', coalesce((select jsonb_agg(jsonb_build_object(
'phone', n.phone, 'town', n.town, 'kind', n.kind, 'attempts', n.attempts, '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), '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, 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) '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), '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) 'sources', coalesce((select jsonb_agg(jsonb_build_object('source', ps.source, 'count', ps.cnt)
order by ps.cnt desc) from path_sources ps), '[]'::jsonb), order by ps.cnt desc)
-- Каким каналом согласовали дату: push / sms / звонок робота / вручную (логист). 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) '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; from stages s, pct p, path pp;
$function$; $function$;
comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is
'Воронка коммуникаций для аналитики: этапы push → SMS → ссылка → звонки → согласование → ручная работа → выдача.'; 'Воронка коммуникаций для аналитики: этапы push → SMS → ссылка → звонки → согласование → ручная работа → выдача. Тестовые звонки исключены, канал согласования считается по событию.';

View File

@ -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