Compare commits

...

42 Commits

Author SHA1 Message Date
hermes 2067ff7d89 fix(sms): кампании больше не долбят один номер бесконечно (код 107 у сотен SMS)
Причина «кучи ошибок 107»: номер попытки читался из строки лога
(attempts = item.get("attempts", 1)), а каждая повторная отправка вставляла
НОВУЮ строку с attempts=1. Условие attempts < max_attempts выполнялось всегда,
поэтому группа уходила в повтор каждые 5 минут бесконечно: заказ ТП-010543
(9780187307) — 461 отправка за 3,5 суток, ни одна не доставлена, в ручную
работу заказ не ушёл.

- номер попытки считается по группе (count строк кампании) и пишется в строку
  и в order_groups.sms_attempts;
- в выборке на отправку жёсткий лимит попыток — повтор невозможен даже при
  сбое статуса;
- попытки исчерпаны → manual_required («нужен менеджер»), а не send_failed;
- новый шаг 4: группа с исчерпанными попытками, застрявшая в link_ready,
  сама уходит в manual_required (самолечение);
- то же исправление во второй кампании (second_sms) — там тот же баг.
2026-09-21 17:28:46 +02:00
Hermes c58f2b0629 fix(ui): закрытый баннер SMS больше не возвращается (отпечаток без суммы), баланс один на всех страницах; feat(autocall): статусы «не выбрал день» / «не выбрал время» / «и то и другое» (БД + бейдж + фильтр) 2026-09-17 12:32:53 +02:00
Hermes 775a72ea29 fix(autocall): «ручное управление» — это НЕ согласованный заказ; кнопку автодозвона по нему больше не блокирует (ошибка из прошлой правки) 2026-09-17 12:04:00 +02:00
Hermes 512aab9ff2 fix(sms): заказы больше не висят в «1-е приглашение отправлено» — в ручную работу переводим от последнего контакта и только по включённым каналам (SMS/автодозвон); заказ, где дата уже выбрана, роботу не звоним 2026-09-16 17:15:43 +02:00
Hermes e35b3d885f fix(autocall): заявку «позвонить сейчас» не закрываем — при закрытом окне звонка блокируем кнопку автодозвона с объяснением (по правилу владельца) 2026-09-16 16:47:49 +02:00
Hermes dacd43165c fix(autocall): заявка «позвонить сейчас» с закрытым окном звонка больше не висит вечно в очереди — планировщик закрывает её, карточка честно говорит «звонить больше не о чем» + кнопка «всё равно позвонить» 2026-09-16 16:39:28 +02:00
Hermes b965216181 feat(sms): в карточке объяснён статус «ручное управление» без ошибок отправки — SMS не отправлялись, согласование шло без SMS 2026-09-16 16:30:02 +02:00
Hermes 4192e44998 feat(sms): понятные причины ошибок в карточке доставки — техстроки планировщика (code=106, not delivered, limit exceeded) переводятся в человеческие 2026-09-16 16:19:17 +02:00
Hermes 5c98a54895 feat(autocall): звонок клиенту, если SMS не ушло (отсчёт от последней попытки) + статусы «не выбрал день/время» вместо «не нажал цифру» 2026-09-16 16:14:34 +02:00
Hermes 7ab3debbb3 fix(logistics): понятные статусы автодозвона в реестре доставок — своя подпись у каждого исхода, «не звонили» только по ожидающим заказам, убраны формулировки удалённых сценариев; единый список подписей для фильтра и бейджа 2026-09-16 11:03:28 +02:00
Hermes aeb4bbf63f fix(autocall): убраны сценарии «0» и «9» из диалога и из презентации; пауза между звонками 1 час; граница половины дня 13:00; в презентации готовность по обмену с 1С, без готовности в субботу 2026-09-16 10:36:01 +02:00
Hermes 833d2dcd44 fix(autocall): в презентации готовность приходит по обмену с 1С, а не отметкой менеджера; PDF и HTML собираются из одного файла содержания 2026-09-15 18:02:22 +02:00
Hermes d9ded99e81 docs(autocall): HTML-версия презентации из общего файла содержания (deck_content.py), проверка совпадения текста с PDF 2026-09-15 15:50:19 +02:00
Hermes ed89222834 docs(autocall): презентация «Автодозвон: как это работает» (PDF, 16 слайдов) + скрипт сборки и проверки вёрстки 2026-09-15 15:38:49 +02:00
Hermes 8e9a0f6dab fix(autocall): ручной звонок из карточки звонит клиенту (тестовый режим только для массового обзвона) + видимый процесс звонка в карточке 2026-09-15 15:26:20 +02:00
Hermes 5a71fbceba docs(ats): образцы звучания собираются из списка файлов AGI-харнесса + скрипт сборки (в ручном образце «в пятницу» вместо «сегодня») 2026-09-15 09:56:37 +02:00
Hermes 47b3ccf28f fix(autocall): убран сценарий «0 — позвать менеджера» — такой цифры в диалоге нет, менеджер подключается только эскалацией 2026-09-15 09:50:26 +02:00
Hermes 44f58ce9e7 fix(autocall): доставка предлагает реальные рабочие дни графика (пт → пн/вт), эскалация по настройкам админки, WAIT FOR DIGIT и «0 — менеджер» 2026-09-15 09:40:43 +02:00
Hermes 3706f64ca7 feat(orders): блок про SMS смотрит на баланс счёта — гасится после пополнения, закрывается крестиком, предупреждает о низком балансе (эдж-функция sms-balance) 2026-09-14 15:58:30 +02:00
Hermes dc262d2be7 feat(orders): причина неудачной отправки SMS — в карточке заказа и блоком над таблицей наборов (код 201 = кончились средства) 2026-09-14 15:40:15 +02:00
Hermes 6ebdc41667 fix(ats): название компании «Супер Сам» (пробел) — выбор владельца, залито на АТС 2026-09-14 14:51:31 +02:00
Hermes f9395d7d05 fix(ats): название компании раздельно в озвучках (Супер, Сам) + инструкция по генерации и проверке озвучек 2026-09-14 14:27:26 +02:00
Hermes 1660cced66 feat(schedule): дни SMS в ЛК реально управляют рассылками — скрипты читают sms_days из графика 2026-09-14 14:14:37 +02:00
Hermes 1a310e5f5a fix(pickup): окно самовывоза в рабочих днях (пятница → пт, пн, вт) у робота и в ссылке; тесты окна 2026-09-14 13:59:13 +02:00
Hermes 11167516a3 feat(schedule): рабочие часы склада и доставки в админке — открытие/закрытие, границы половин, отсечка «сегодня»; робот и ссылка клиента читают часы 2026-09-14 13:15:49 +02:00
Hermes 794c3ce5e3 fix(autocall,client): рабочие дни из графика админки — самовывоз пн-пт у робота и ссылки, доставка в ссылке читает график; тексты панели «Рабочие дни» 2026-09-11 15:53:03 +02:00
Hermes 270ab39abc fix(autocall): самовывоз только по рабочим дням графика + робот называет реальные дни; страница клиента читает график 2026-09-11 15:40:29 +02:00
Hermes 139c895290 feat(autocall): локации — переключатели звонков по городам в админке (авто-дозвон), гео-правила общие с SMS 2026-09-11 11:59:44 +02:00
Hermes f62a6b525b feat(autocall): окно самовывоза 3 дня (сегодня/завтра/послезавтра), даты из диалога, гео-исключения как у SMS 2026-09-11 11:29:49 +02:00
Hermes feb3fc1b07 fix(agi): обрыв диалога — отдаём результат в БД (иначе звонок задним числом записывался как «не взял трубку») 2026-09-11 11:05:57 +02:00
Hermes c4e2c4a181 feat(autocall): самовывоз — тип звонка pkp/dlv из карточки заказа, выключатель самовывоза, тип в логе и очереди 2026-09-11 10:53:53 +02:00
Hermes 46a3609233 fix(autocall): исход «не взял трубку» — добор истёкших заявок + детект объявления оператора в AGI 2026-09-10 15:27:59 +02:00
Hermes 73b9512f15 fix(orders): список не пустеет при возврате — кэш отдаёт обёртку {data,error}, состояние не стирается 2026-09-10 15:04:47 +02:00
Hermes 52dc913fa7 fix(autocall): бейдж в таблице — «Автодозвон ✅ / ❌» вместо «слот от робота» 2026-09-10 14:53:44 +02:00
Hermes 9d1c74eda0 fix(autocall): сводка показывает последний успешный звонок; текст в сводке переносится вместо обрезки 2026-09-10 13:15:47 +02:00
Hermes f378fd05ef fix(autocall): блок «Автодозвон» не появлялся в карточке, если порядок блоков сохранён в localStorage — мердж с дефолтом 2026-09-10 11:32:14 +02:00
Hermes c7a35cdd29 feat(autocall): история звонков в карточке — как у SMS (попытки, слот, сценарий, длительность) 2026-09-10 11:28:39 +02:00
Hermes 04f78f7367 feat(filters): два отдельных фильтра «Статус SMS» и «Статус автодозвона» вместо «Прозвонить» 2026-09-09 14:49:31 +02:00
Hermes 587e12f562 feat(filters): единое поле «Период» без лейблов + упрощённый фильтр «Прозвонить» 2026-09-09 11:59:32 +02:00
Hermes 64bcda8eed feat(autocall): статус автодозвона в карточке (как у SMS) + фильтр «Прозвонить» в таблице + выравнивание переключателя 2026-09-08 13:04:01 +02:00
Hermes 1552c21d99 feat(autocall): автодозвон в карточке доставки — история действий, цепочка этапов, сводка статусов 2026-09-08 12:28:38 +02:00
Hermes 5dbeae84f4 feat(autocall): исключения автодозвона — таблица, UI в настройках, проверка в планировщике, бейдж в реестре/карточке 2026-09-08 11:16:16 +02:00
62 changed files with 5717 additions and 332 deletions

54
ats-patch/apply.sh Normal file
View File

@ -0,0 +1,54 @@
#!/bin/bash
# Применение правок автодозвона SuperSam на АТС. Идемпотентно, с проверкой.
set -e
cd /root/ats-patch
echo "=== 1. extensions.conf: добавить контекст SS-Autocall-Slot ==="
if grep -q '^\[SS-Autocall-Slot\]' /etc/asterisk/extensions.conf; then
echo " уже есть, пропускаю"
else
cat ss-autocall-context.txt >> /etc/asterisk/extensions.conf
echo " добавлен"
fi
echo "=== 2. modules.conf: автозагрузка pbx_spool + res_agi ==="
if grep -q '^load = pbx_spool.so' /etc/asterisk/modules.conf; then
echo " pbx_spool уже есть"
else
# вставить после 'load = pbx_config.so' в секции Core/PBX
sed -i '/^load = pbx_config.so/a load = pbx_spool.so\nload = res_agi.so' /etc/asterisk/modules.conf
echo " добавлены pbx_spool + res_agi"
fi
echo "=== 3. ss-dialog.agi: вставить curl результата перед HANGUP ==="
if grep -q 'отправка результата в БД' /var/lib/asterisk/agi-bin/ss-dialog.agi; then
echo " curl уже есть"
else
# вставить блок перед строкой 'say "HANGUP"'
python3 - <<'PY'
import re
p = '/var/lib/asterisk/agi-bin/ss-dialog.agi'
src = open(p).read()
block = open('/root/ats-patch/ss-agi-curl.txt').read()
if 'say "HANGUP"' in src and 'отправка результата в БД' not in src:
src = src.replace('say "HANGUP"', block + '\nsay "HANGUP"', 1)
open(p, 'w').write(src)
print(' curl вставлен')
else:
print(' пропуск (HANGUP не найден или уже есть)')
PY
fi
echo "=== 4. проверка синтаксиса ==="
asterisk -rx "dialplan reload" 2>&1 | tail -3
asterisk -rx "module load pbx_spool.so" 2>&1 | tail -1
asterisk -rx "module load res_agi.so" 2>&1 | tail -1
echo "--- dialplan show SS-Autocall-Slot ---"
asterisk -rx "dialplan show SS-Autocall-Slot" 2>&1 | head -10
echo "--- module show like pbx_spool ---"
asterisk -rx "module show like pbx_spool" 2>&1 | grep -i running
echo "--- module show like res_agi ---"
asterisk -rx "module show like res_agi" 2>&1 | grep -i running
echo "--- bash syntax ss-dialog.agi ---"
bash -n /var/lib/asterisk/agi-bin/ss-dialog.agi && echo " bash -n OK"
echo "=== DONE ==="

27
ats-patch/ss-agi-curl.txt Normal file
View File

@ -0,0 +1,27 @@
# ─── отправка результата в БД (edge function autocall-result) ───────
# Переменные приходят из call-файла (Setvar). При потере сети звонок не
# валится: curl с таймаутом, результат всё равно остался в $OUT.
if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
curl -s --max-time 5 -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "x-webhook-secret: $API_KEY" \
-d "{
\"asterisk_uniqueid\":\"$UNIQ\",
\"order_group_id\":\"${GROUP_ID:-}\",
\"campaign_type\":\"autocall_slot\",
\"customer_phone\":\"${CUSTOMER_PHONE:-}\",
\"attempt_no\":${ATTEMPT:-1},
\"scenario\":\"$SCENARIO\",
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"duration_sec\":0,
\"sip_code\":200,
\"channel_state\":\"Up\",
\"dtmf_day\":${CONF_DAY:-null},
\"dtmf_half\":${CONF_HALF:-null},
\"changed\":\"$CHANGED\",
\"outcome\":\"$OUTCOME\",
\"was_test_mode\":${TEST_MODE:-0}
}" >/dev/null 2>&1 || echo "curl result failed (network?)" >> "$LOG"
fi

View File

@ -0,0 +1,13 @@
; ===== SS-Autocall-Slot (SuperSam автодозвон) — добавлено 2026-09-01 =====
[SS-Autocall-Slot]
; Автодозвон SuperSam: робот (Vivienne) опрашивает клиента по DTMF.
; Вызывается из call-файла планировщика (Context: SS-Autocall-Slot).
; Переменные SCENARIO/CALLTYPE/GROUP_ID/ATTEMPT/API_URL/API_KEY/TEST_MODE
; приходят из call-файла через Setvar.
exten => s,1,NoOp(SS-Autocall-Slot: scenario=${SCENARIO} group=${GROUP_ID} attempt=${ATTEMPT})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(ss-dialog.agi,${SCENARIO},${CALLTYPE})
same => n,Hangup()
; ===== конец SS-Autocall-Slot =====

6
ats-patch/ss-modules.txt Normal file
View File

@ -0,0 +1,6 @@
; ===== SuperSam автодозвон: автозагрузка модулей (добавлено 2026-09-01) =====
; Без этого ночной reboot 03:33 сносит pbx_spool (call-файлы не подхватываются).
load = pbx_spool.so
load = res_agi.so
; ===== конец =====

View File

@ -68,6 +68,49 @@ load = res_agi.so
; Это чинит ночной reboot 03:33: сейчас autoload=no, pbx_spool грузится ; Это чинит ночной reboot 03:33: сейчас autoload=no, pbx_spool грузится
; вручную и после ребута слетает — автодозвон молча умирает. ; вручную и после ребута слетает — автодозвон молча умирает.
=====================================================================
## ПРАВКА 4 — Запись разговоров автодозвона (MixMonitor)
=====================================================================
Зачем: в карточке заказа нужно послушать звонок робота — слышно, что клиент
говорит и нажимает ли кнопки. Плюс это основа для распознавания голосового
ответа (клиент называет день вместо кнопки).
Файл: /etc/asterisk/extensions.conf — в контекст [SS-Autocall-Slot]
В контексте УЖЕ есть готовый [sub-monitor] (папка по дате + mp3 через lame).
Вызываем его первым делом и останавливаем запись на завершении:
[SS-Autocall-Slot]
; Запись разговора: аргументы (1)=метка(order group), (2)=номер, (3)=тип
exten => s,1,GoSub(sub-monitor,s,1(${GROUP_ID},${CUSTOMER_PHONE},autocall))
; ВАЖНО: в [sub-monitor] MixMonitor включается с опцией «b» (писать только после
; соединения с абонентом). Для робота абонента-человека нет — файл получится пустым,
; поэтому при вызове из автодозвона нужен вариант без «b». Если оставлять общий
; [sub-monitor], просим убрать «b» только для этого вызова (например, отдельным
; [sub-monitor-robot] с той же логикой, но MixMonitor(${WAV}.wav,,${monopt})).
same => n,NoOp(SS-Autocall-Slot: scenario=${SCENARIO} group=${GROUP_ID} attempt=${ATTEMPT})
same => n,Answer()
same => n,Wait(1)
same => n,AGI(ss-dialog.agi,${SCENARIO},${CALLTYPE})
same => n,Hangup()
exten => h,1,StopMixMonitor()
Файл: /etc/asterisk/extensions.conf — альтернатива без правки [sub-monitor]
(если Паша не хочет трогать общий контекст):
[SS-Autocall-Slot]
same => n,Set(FNAME=${STRFTIME(${EPOCH},,%Y-%m-%d_%H-%M-%S)}_${CUSTOMER_PHONE}_${GROUP_ID})
same => n,Set(RECORDDIR=/var/spool/asterisk/monitor/${STRFTIME(${EPOCH},,%Y/%m/%d)})
same => n,Set(MONOPT=nice -n 19 mkdir -p ${RECORDDIR} && chmod 777 ${RECORDDIR} && /usr/bin/lame -b 32 --silent "${RECORDDIR}/${FNAME}.wav" "${RECORDDIR}/${FNAME}.mp3" && rm -f "${RECORDDIR}/${FNAME}.wav" && chmod o+r "${RECORDDIR}/${FNAME}.mp3")
same => n,MixMonitor(${RECORDDIR}/${FNAME}.wav,,${MONOPT})
...далее Answer/Wait/AGI как сейчас...
exten => h,1,StopMixMonitor()
Место: ~1.5 МБ на 2 минуты при 32 кбит/с; на диске 50 ГБ, занято 26 ГБ.
Разбор и выгрузку записей в карточку заказа делаем мы (по расписанию, вне АТС).
; --- конец правки 4 ---
===================================================================== =====================================================================
## ПРОВЕРКА ПОСЛЕ ПРИМЕНЕНИЯ (на АТС) ## ПРОВЕРКА ПОСЛЕ ПРИМЕНЕНИЯ (на АТС)
===================================================================== =====================================================================

23
docs/ats/gen-brand-prompts.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Озвучки с названием компании — вариант владельца: «Супер Сам» (пробел).
# Каждый файл синтезируем отдельным вызовом edge-tts: в Python-цикле два разных
# текста один раз вышли побайтово одинаковыми (артефакт синтезатора).
set -e
VOICE="fr-FR-VivienneMultilingualNeural"
OUT=/tmp/brand_space
mkdir -p "$OUT"
gen () { # $1 = имя файла, $2 = текст
edge-tts --voice "$VOICE" --text "$2" --write-media "$OUT/$1.mp3" >/dev/null 2>&1
ffmpeg -v error -y -i "$OUT/$1.mp3" -ar 8000 -ac 1 -c:a pcm_s16le "$OUT/$1.wav"
rm -f "$OUT/$1.mp3"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT/$1.wav")
printf "%s %.2f с %s байт\n" "$1" "$dur" "$(stat -c%s "$OUT/$1.wav")"
}
gen ss_pkp_intro "Здравствуйте! Это Супер Сам. Ваш заказ готов к выдаче. Выберите день самовывоза."
gen ss_c_intro_dlv "Здравствуйте! Это Супер Сам. Ваша доставка запланирована на"
gen ss_c_intro_pkp "Здравствуйте! Это Супер Сам. Ваш самовывоз запланирован на"
echo "--- контрольные суммы (должны быть разными) ---"
md5sum "$OUT"/*.wav

57
docs/ats/make-samples.sh Normal file
View File

@ -0,0 +1,57 @@
#!/bin/bash
# Образцы звучания робота для владельца: собираются ТОЛЬКО из тех файлов и в том
# порядке, который выводит AGI-харнесс (bash ss-dialog.agi с подставленными Setvar).
#
# Зачем правило: 15.09 образец собрали руками и в первый вариант (сегодняшний день)
# подставили «в пятницу» вместо «сегодня» — владелец решил, что робот называет день
# недели вместо «сегодня». Робот был прав, ошибочен был образец.
#
# Порядок фраз берём из вывода прогонов:
# python3 /tmp/check_today_wording.py (слово «сегодня» для сегодняшнего дня)
# python3 /tmp/agi_harness_dlv.py (сценарии доставки и самовывоза)
# там в строках «играет:» перечислены реальные файлы.
#
# Запуск: bash make-samples.sh → mp3 в /root/robot_samples/ (отправить владельцу в чат).
set -euo pipefail
W=${W:-/root/ss_wavs} # копии озвучек с АТС (tar с /var/lib/asterisk/sounds/ru)
N=${N:-/tmp/dlv_frags} # новые фрагменты доставки
OUT=${OUT:-/root/robot_samples}
mkdir -p "$OUT"
mix () { # $1=имя результата, дальше — список файлов
name=$1; shift
args=(); for f in "$@"; do args+=(-i "$f"); done
filt=""; for i in $(seq 0 $(($# - 1))); do filt="${filt}[$i:a]"; done
ffmpeg -v error -y "${args[@]}" -filter_complex "${filt}concat=n=$#:v=0:a=1[out]" \
-map "[out]" -ar 8000 -ac 1 -b:a 64k "$OUT/$name.mp3"
printf "%-38s %5s КБ\n" "$name.mp3" "$(( $(stat -c%s "$OUT/$name.mp3") / 1024 ))"
}
# 1. Самовывоз, звонок в пятницу по готовому заказу: сегодня / понедельник / вторник
mix samovyvoz_pyatnica_segodnya \
"$W/ss_pkp_intro.wav" \
"$W/ss_pkp_menu1.wav" "$W/ss_pkp_d_today.wav" \
"$W/ss_pkp_menu2.wav" "$W/ss_pkp_d_1.wav" \
"$W/ss_pkp_menu3.wav" "$W/ss_pkp_d_2.wav" \
"$W/ss_half_pkp.wav" "$W/ss_ok_first.wav" "$W/ss_tail_pkp.wav"
# 2. Доставка, заказ готов в пятницу: понедельник и вторник (в день готовности не возим)
mix dostavka_pyatnica_pon_vt \
"$N/ss_dlv_intro.wav" \
"$N/ss_dlv_menu1.wav" "$W/ss_pkp_d_1.wav" \
"$N/ss_dlv_menu2.wav" "$W/ss_pkp_d_2.wav" \
"$W/ss_half_dlv.wav" "$W/ss_ok_first.wav" "$W/ss_tail_dlv.wav"
# 3. Доставка, заказ готов в четверг, звонок в пятницу: сегодня и понедельник
mix dostavka_segodnya_ponedelnik \
"$N/ss_dlv_intro.wav" \
"$N/ss_dlv_menu1.wav" "$W/ss_pkp_d_today.wav" \
"$N/ss_dlv_menu2.wav" "$W/ss_pkp_d_1.wav" \
"$W/ss_half_dlv.wav" "$W/ss_ok_first.wav" "$W/ss_tail_dlv.wav"
# 4. Самовывоз, обычный день, окно подряд: сегодня / завтра / послезавтра
mix samovyvoz_obychnyy_den \
"$W/ss_pkp_intro.wav" \
"$W/ss_pkp_day3.wav" \
"$W/ss_half_pkp.wav" "$W/ss_ok_first.wav" "$W/ss_tail_pkp.wav"

View File

@ -0,0 +1,44 @@
# Озвучки робота: генерация и правки
Все реплики робота — edge-tts, голос **`fr-FR-VivienneMultilingualNeural`**, формат
8 кГц / моно / 16 бит PCM WAV. Лежат на АТС в `/var/lib/asterisk/sounds/ru/` (`ss_*.wav`),
имя файла = имя реплики в `ss-dialog.agi`.
```bash
edge-tts --voice fr-FR-VivienneMultilingualNeural --text "<текст>" --write-media out.mp3
ffmpeg -v error -y -i out.mp3 -ar 8000 -ac 1 -c:a pcm_s16le out.wav
```
Деплой (всегда с бэкапом — был случай, когда файл на АТС обнулился):
`scp` в `/tmp` на АТС → `test -s` → `cp` в `/var/lib/asterisk/sounds/ru/` →
`chown asterisk:asterisk` + `chmod 644` → сверить `md5sum` с локальным.
## Название компании — только раздельно
Владелец попросил, чтобы «СуперСам» звучало раздельно. Проверено синтезом (пауза внутри названия):
| Текст в подсказке | Пауза внутри названия | Вердикт |
|---|---|---|
| `СуперСам` | нет | было в проде — владелец забраковал, звучит слитно |
| **`Супер Сам`** | +24 мс | **выбор владельца (14.09), стоит в проде** |
| `Супер, Сам` | +0,45 с | пробовал, владелец отклонил — запятая слышится как обращение |
| `Супер... Сам` | +0,7 с | звучит неестественно |
Речь в подсказке: `Здравствуйте! Это Супер Сам. <дальше по сценарию>`.
Файлы с названием компании: `ss_pkp_intro`, `ss_c_intro_dlv`, `ss_c_intro_pkp`
(в сценариях A и B компания не представляется).
⚠️ Владелец выбирает озвучку на слух: перед публикацией спорного варианта отправлять ему
короткие пробники в чат (Telegram), а не решать по метрикам — по цифрам «Супер Сам» почти
не отличается от слитного варианта, но ему слышно.
## Проверка правки
- **Текст** — распознать свой wav (`faster_whisper`, `small`, `language="ru"`): вернёт то, что
услышит человек. Whisper пишет название одним словом («Суперсан») даже при раздельном
произношении — по нему судить о паузе нельзя.
- **Пауза внутри названия** — по энергии сигнала (RMS по кадрам 10 мс, порог −32 дБ от пика):
у правильной версии появляется провал ≥ 0,3 с в районе названия, у старой его нет.
- ⚠️ Генерировать файлы по одному в отдельных вызовах `edge-tts`: в цикле внутри Python два
разных текста один раз вышли побайтово одинаковыми (артефакт), поэтому перед деплоем
сверять `md5sum` всех файлов между собой и с ожидаемым.

View File

@ -27,7 +27,28 @@ OUT="${RES}-${UNIQ}.txt"
say () { echo "$1"; read -r resp; echo "$(date +%T) $1 -> $resp" >> "$LOG"; RESP="$resp"; } say () { echo "$1"; read -r resp; echo "$(date +%T) $1 -> $resp" >> "$LOG"; RESP="$resp"; }
digit () { # $1=озвучка, $2=таймаут мс -> DIGIT digit () { # $1=озвучка, $2=таймаут мс -> DIGIT
say "GET DATA $1 ${2:-9000} 1" say "GET DATA $1 ${2:-9000} 1"
DIGIT=$(echo "$RESP" | sed -n 's/.*result=\([0-9]*\).*/\1/p') # GET DATA отдаёт саму цифру (200 result=1). Тишина приходит как «(timeout)»,
# а нажатый «0» — как result=0: раньше их путали и «0» (позвать менеджера)
# был неотличим от молчания.
case "$RESP" in
*"(timeout)"*|*"result=-1"*) DIGIT="" ;;
*) DIGIT=$(echo "$RESP" | sed -n 's/^200 result=\([0-9]*\)$/\1/p') ;;
esac
[ -n "$DIGIT" ] && ANY_DIGIT=1
}
wait_digit () { # $1=таймаут мс -> DIGIT (после EXEC Playback; 0 у Asterisk = тишина)
say "WAIT FOR DIGIT ${1:-9000}"
# WAIT FOR DIGIT возвращает ASCII-код нажатой цифры (49 = «1»), 0 = тишина,
# -1 = обрыв. Раньше код сравнивал ASCII с «1»/«2», поэтому меню из фрагментов
# не принимало ни одного нажатия — превращаем код в символ.
RAW=$(echo "$RESP" | sed -n 's/^200 result=\(-\{0,1\}[0-9]*\)$/\1/p')
if [ -z "$RAW" ] || [ "$RAW" -le 0 ]; then
DIGIT=""
else
DIGIT=$(printf "\\$(printf '%03o' "$RAW")")
ANY_DIGIT=1
fi
} }
# Чтение channel-переменной из call-файла (Setvar) через AGI. # Чтение channel-переменной из call-файла (Setvar) через AGI.
getvar () { # $1=имя -> VAR getvar () { # $1=имя -> VAR
@ -36,6 +57,26 @@ getvar () { # $1=имя -> VAR
} }
play () { say "STREAM FILE $1 \"\""; } play () { say "STREAM FILE $1 \"\""; }
# Обрыв: клиент положил трубку посередине диалога. Asterisk шлёт SIGHUP и убивает
# скрипт — без этого обработчика результат не уходил в БД, и звонок задним числом
# записывался как «не взял трубку», хотя человек ответил. Сохраняем то, что успели.
on_interrupt () {
if [ -z "$OUTCOME" ]; then
if [ -n "$HALF" ]; then
OUTCOME=no_input_half # день выбрал, время не назвал
elif [ -n "$DAY" ] && [ "$DAY" != "0" ]; then
OUTCOME=no_input_half # день выбран, до времени не дошло
elif [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer # объявление оператора: человека не было
else
OUTCOME=no_input_day # ответил, но ничего не нажал
fi
save
fi
exit 0
}
trap on_interrupt HUP TERM INT
# Channel-переменные из call-файла (Setvar). Читаем ДО диалога, т.к. AGI # Channel-переменные из call-файла (Setvar). Читаем ДО диалога, т.к. AGI
# не видит их как bash-переменные — только через GET VARIABLE. # не видит их как bash-переменные — только через GET VARIABLE.
getvar API_URL; API_URL=$VAR getvar API_URL; API_URL=$VAR
@ -44,6 +85,16 @@ getvar GROUP_ID; GROUP_ID=$VAR
getvar ATTEMPT; ATTEMPT=$VAR getvar ATTEMPT; ATTEMPT=$VAR
getvar TEST_MODE; TEST_MODE=$VAR getvar TEST_MODE; TEST_MODE=$VAR
getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR
# Даты самовывоза (заполняет планировщик по окну «готовность + 0..2 дня»)
getvar PICKUP_DAY1; PICKUP_DAY1=$VAR
getvar PICKUP_DAY2; PICKUP_DAY2=$VAR
getvar PICKUP_DAY3; PICKUP_DAY3=$VAR
getvar PICKUP_DAY1_FIRST; PICKUP_DAY1_FIRST=$VAR
# Даты доставки (планировщик берёт рабочие дни графика доставки из админки:
# не в день готовности и без выходных — готов в пятницу → понедельник и вторник)
getvar DELIVERY_DAY1; DELIVERY_DAY1=$VAR
getvar DELIVERY_DAY2; DELIVERY_DAY2=$VAR
CHOSEN_DATE=""
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку, # Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
# выбор всё равно сохранён (AGI умирает молча, финальной записи не будет). # выбор всё равно сохранён (AGI умирает молча, финальной записи не будет).
@ -63,7 +114,7 @@ save () {
# Отправляем в БД сразу при финальном исходе (клиент может бросить трубку # Отправляем в БД сразу при финальном исходе (клиент может бросить трубку
# в любой момент — результат должен успеть уйти). Идемпотентность по uniqueid. # в любой момент — результат должен успеть уйти). Идемпотентность по uniqueid.
case "$OUTCOME" in case "$OUTCOME" in
confirmed|confirmed_kept_previous|confirmed_silent|no_answer|no_input_day|no_input_half|operator|opt_out|invalid_number|error) confirmed|confirmed_kept_previous|confirmed_silent|no_answer|no_input_day|no_input_half|operator|invalid_number|error)
send_result ;; send_result ;;
esac esac
} }
@ -83,6 +134,8 @@ send_result () {
\"customer_phone\":\"${CUSTOMER_PHONE:-}\", \"customer_phone\":\"${CUSTOMER_PHONE:-}\",
\"attempt_no\":${ATTEMPT:-1}, \"attempt_no\":${ATTEMPT:-1},
\"scenario\":\"$SCENARIO\", \"scenario\":\"$SCENARIO\",
\"call_type\":\"${TYPE:-dlv}\",
\"chosen_date\":\"${CHOSEN_DATE:-}\",
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
\"duration_sec\":0, \"duration_sec\":0,
@ -104,6 +157,19 @@ save
say "ANSWER" say "ANSWER"
say "EXEC Wait 1" say "EXEC Wait 1"
# Сколько гудело до «ответа»: старт AGI минус время создания канала.
# Если «ответ» пришёл после долгого гудка, а цифр за весь диалог не было —
# это объявление оператора (сброс/недоступен), а не человек. Такие звонки
# пишем как no_answer, а не «не нажал». Порог 20 сек: живые обычно берут
# раньше; твой сброшенный звонок гудел ~25 сек до объявления.
if [ -n "$UNIQ" ]; then
RING_SEC=$(($(date +%s) - ${UNIQ%%.*}))
[ "$RING_SEC" -lt 0 ] && RING_SEC=0
else
RING_SEC=0
fi
ANY_DIGIT=""
# ─── C: подтверждение уже выбранного слота ─────────────────────────── # ─── C: подтверждение уже выбранного слота ───────────────────────────
if [ "$SCENARIO" = "C" ]; then if [ "$SCENARIO" = "C" ]; then
play "ru/ss_c_intro_${TYPE}" play "ru/ss_c_intro_${TYPE}"
@ -121,6 +187,183 @@ if [ "$SCENARIO" = "C" ]; then
fi fi
fi fi
# ─── Самовывоз: даты приходят из call-файла (PICKUP_DAY1..3) ─────────
# Робот называет конкретные дни, окно считает планировщик:
# готов сегодня → сегодня/завтра/послезавтра, вчера → сегодня/завтра, позавчера → только сегодня.
# Отвечаем выбранной датой (chosen_date), а не абстрактным «день 1/2».
if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
play "ru/ss_pkp_intro"
DAYS=1
[ -n "$PICKUP_DAY2" ] && DAYS=2
[ -n "$PICKUP_DAY3" ] && DAYS=3
# Дни идут подряд от сегодня → играем готовые подсказки (живая фраза целиком).
# Если внутри окна выходной (например, пятница → понедельник), подсказка
# «послезавтра» соврала бы — собираем меню из фрагментов и называем реальные дни.
PICK_TODAY=$(date +%F)
PICK_TOMORROW=$(date -d "+1 day" +%F)
PICK_DAYAFTER=$(date -d "+2 days" +%F)
CONTIG=1
# Первый вариант — всегда «сегодня»; если окно начинается не с сегодня
# (звонок попал на выходной), подсказка «сегодня» соврала бы.
if [ "$PICKUP_DAY1" != "$PICK_TODAY" ]; then CONTIG=0; fi
if [ "$DAYS" -ge 2 ] && [ "$PICKUP_DAY2" != "$PICK_TOMORROW" ]; then CONTIG=0; fi
if [ "$DAYS" -ge 3 ] && [ "$PICKUP_DAY3" != "$PICK_DAYAFTER" ]; then CONTIG=0; fi
if [ "$CONTIG" != "1" ]; then
day_frag () {
case "$1" in
"$PICK_TODAY") echo "ru/ss_pkp_d_today" ;;
"$PICK_TOMORROW") echo "ru/ss_pkp_d_tomorrow" ;;
*) echo "ru/ss_pkp_d_$(date -d "$1" +%u)" ;;
esac
}
if [ "$DAYS" = "1" ]; then
PICK_MENU="ru/ss_pkp_only&$(day_frag "$PICKUP_DAY1")"
else
PICK_MENU="ru/ss_pkp_menu1&$(day_frag "$PICKUP_DAY1")"
[ -n "$PICKUP_DAY2" ] && PICK_MENU="${PICK_MENU}&ru/ss_pkp_menu2&$(day_frag "$PICKUP_DAY2")"
[ -n "$PICKUP_DAY3" ] && PICK_MENU="${PICK_MENU}&ru/ss_pkp_menu3&$(day_frag "$PICKUP_DAY3")"
fi
fi
while [ -z "$OUTCOME" ]; do
if [ "$CONTIG" = "1" ]; then
digit "ru/ss_pkp_day${DAYS}" 9000
else
say "EXEC Playback \"$PICK_MENU\""
wait_digit 9000
fi
CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;;
esac
if [ -z "$CHOSEN_DAY" ]; then
if [ "$CONTIG" = "1" ]; then
digit "ru/ss_pkp_day${DAYS}" 9000
else
say "EXEC Playback \"$PICK_MENU\""
wait_digit 9000
fi
CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;;
esac
fi
if [ -z "$CHOSEN_DAY" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break
fi
play "ru/ss_no_input"; OUTCOME=no_input_day; break
fi
case "$CHOSEN_DAY" in
1) CHOSEN_DATE="$PICKUP_DAY1" ;;
2) CHOSEN_DATE="$PICKUP_DAY2" ;;
3) CHOSEN_DATE="$PICKUP_DAY3" ;;
esac
DAY=$CHOSEN_DAY
save
# Половина дня: если осталось только «сегодня» и первая половина уже прошла — не спрашиваем.
if [ "$CHOSEN_DAY" = "1" ] && [ "$PICKUP_DAY1_FIRST" != "1" ]; then
HALF=2
play "ru/ss_ok_second"
else
digit "ru/ss_half_pkp" 9000
HALF=$DIGIT
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
digit "ru/ss_half_pkp" 9000
HALF=$DIGIT
fi
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break
fi
play "ru/ss_no_input"; OUTCOME=no_input_half; break
fi
[ "$HALF" = "1" ] && play "ru/ss_ok_first" || play "ru/ss_ok_second"
fi
CONF_DAY=$CHOSEN_DAY; CONF_HALF=$HALF
OUTCOME=confirmed
play "ru/ss_tail_pkp"
done
fi
# ─── Доставка: даты приходят из call-файла (DELIVERY_DAY1..2) ────────
# Доставляем только рабочими днями из «Графика работы» (админка) и не в день
# готовности: готов в пятницу → предлагаем понедельник и вторник, а не субботу
# с воскресеньем. Робот называет реальные дни, окно считает планировщик.
if [ "$TYPE" = "dlv" ] && [ -n "$DELIVERY_DAY1" ]; then
play "ru/ss_dlv_intro"
DLV_DAYS=1
[ -n "$DELIVERY_DAY2" ] && DLV_DAYS=2
DLV_TODAY=$(date +%F)
DLV_TOMORROW=$(date -d "+1 day" +%F)
dlv_day_frag () {
case "$1" in
"$DLV_TODAY") echo "ru/ss_pkp_d_today" ;;
"$DLV_TOMORROW") echo "ru/ss_pkp_d_tomorrow" ;;
*) echo "ru/ss_pkp_d_$(date -d "$1" +%u)" ;;
esac
}
if [ "$DLV_DAYS" = "1" ]; then
DLV_MENU="ru/ss_dlv_only&$(dlv_day_frag "$DELIVERY_DAY1")"
else
DLV_MENU="ru/ss_dlv_menu1&$(dlv_day_frag "$DELIVERY_DAY1")&ru/ss_dlv_menu2&$(dlv_day_frag "$DELIVERY_DAY2")"
fi
while [ -z "$OUTCOME" ]; do
say "EXEC Playback \"$DLV_MENU\""
wait_digit 9000
CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in
1|2) [ "$CHOSEN_DAY" -le "$DLV_DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;;
esac
if [ -z "$CHOSEN_DAY" ]; then
say "EXEC Playback \"$DLV_MENU\""
wait_digit 9000
CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in
1|2) [ "$CHOSEN_DAY" -le "$DLV_DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;;
esac
fi
if [ -z "$CHOSEN_DAY" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break
fi
play "ru/ss_no_input"; OUTCOME=no_input_day; break
fi
case "$CHOSEN_DAY" in
1) CHOSEN_DATE="$DELIVERY_DAY1" ;;
2) CHOSEN_DATE="$DELIVERY_DAY2" ;;
esac
DAY=$CHOSEN_DAY
save
# Время доставки: до или после середины дня (границу задаёт админка,
# робот говорит просто «первая/вторая половина дня»).
digit "ru/ss_half_dlv" 9000
HALF=$DIGIT
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
digit "ru/ss_half_dlv" 9000
HALF=$DIGIT
fi
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break
fi
play "ru/ss_no_input"; OUTCOME=no_input_half; break
fi
[ "$HALF" = "1" ] && play "ru/ss_ok_first" || play "ru/ss_ok_second"
CONF_DAY=$CHOSEN_DAY; CONF_HALF=$HALF
OUTCOME=confirmed
play "ru/ss_tail_dlv"
done
fi
# ─── выбор дня и времени, с возможностью переделать ────────────────── # ─── выбор дня и времени, с возможностью переделать ──────────────────
while [ -z "$OUTCOME" ]; do while [ -z "$OUTCOME" ]; do
@ -128,7 +371,7 @@ while [ -z "$OUTCOME" ]; do
if [ "$SCENARIO" = "A" ]; then if [ "$SCENARIO" = "A" ]; then
digit "ru/ss_a_day_${TYPE}" digit "ru/ss_a_day_${TYPE}"
DAY=$DIGIT DAY=$DIGIT
if [ "$DAY" != "1" ] && [ "$DAY" != "2" ] && [ "$DAY" != "0" ] && [ "$DAY" != "9" ]; then if [ "$DAY" != "1" ] && [ "$DAY" != "2" ] && [ "$DAY" != "0" ]; then
digit "ru/ss_a_day_${TYPE}" digit "ru/ss_a_day_${TYPE}"
DAY=$DIGIT DAY=$DIGIT
fi fi
@ -137,11 +380,10 @@ while [ -z "$OUTCOME" ]; do
fi fi
save save
if [ "$DAY" = "9" ]; then if [ "$DAY" != "1" ] && [ "$DAY" != "2" ]; then
play "ru/ss_optout"; OUTCOME=opt_out; break if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
elif [ "$DAY" = "0" ]; then OUTCOME=no_answer; break # объявление оператора, человека не было
play "ru/ss_operator"; OUTCOME=operator; break fi
elif [ "$DAY" != "1" ] && [ "$DAY" != "2" ]; then
play "ru/ss_no_input"; OUTCOME=no_input_day; break play "ru/ss_no_input"; OUTCOME=no_input_day; break
fi fi
@ -162,6 +404,9 @@ while [ -z "$OUTCOME" ]; do
fi fi
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
OUTCOME=no_answer; break # объявление оператора, человека не было
fi
play "ru/ss_no_input"; OUTCOME=no_input_half; break play "ru/ss_no_input"; OUTCOME=no_input_half; break
fi fi

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -0,0 +1,369 @@
"""Содержание презентации «Автодозвон: как это работает».
Один источник правды: и PDF (how-it-works.py), и HTML (render-html.py) собираются
отсюда, поэтому версии не разъезжаются при правках.
Факты сверены с кодом: scripts/autocall_scheduler.py, docs/ats/ss-dialog.agi,
supabase/functions/autocall-result/index.ts и настройками боевой системы.
"""
ACCENT, ACCENT2, WARN, DANGER = "accent", "accent2", "warn", "danger"
TITLE_SLIDE = {
"kind": "title",
"kicker": "СУПЕР САМ · ДОСТАВКА И САМОВЫВОЗ",
"title": "Автодозвон: как это работает",
"sub": "Робот звонит клиенту, согласует день и половину дня — без участия оператора",
"lines": [
"Что описано: правила звонка, паузы, поведение в нештатных случаях (не дозвонились,",
"сбросили трубку, не выбрали день/время), где что настраивается и что видит менеджер.",
"",
"Основано на текущем коде и настройках боевой системы, без обещаний и продажи.",
],
}
SLIDES = [
{
"kind": "cards2full",
"title": "Что делает робот",
"sub": "Коротко: он не продаёт и не консультирует — только согласует день и половину дня",
"cards": [
{"title": "Три задачи", "accent": ACCENT, "items": [
"Дозвониться, когда заказ готов и клиент не подтвердил день по ссылке из SMS",
"Предложить конкретные дни голосом и получить выбор цифрой",
"Записать выбор в заказ — дату, половину дня и признак «согласовано роботом»",
]},
{"title": "Чего он не делает", "accent": WARN, "items": [
"Не соединяет с живым менеджером — такой цифры в диалоге нет",
"Не спорит и не уговаривает: нет ответа — повторит по расписанию",
"Не предлагает нерабочие дни и не звонит вне рабочего времени",
]},
],
"full": {"title": "Как это выглядит для клиента", "accent": ACCENT2, "items": [
"Звонок с одного номера, голос один и тот же: «Ваш заказ готов. Выберите день и время»",
"Клиент нажимает цифру дня и цифру половины дня — робот подтверждает выбор вслух",
"После подтверждения даёт возможность изменить выбор (нажатием цифры 2)",
]},
},
{
"kind": "steps",
"title": "Путь заказа до звонка",
"sub": "Когда именно звучит звонок",
"steps": [
("1. Заказ из 1С", "приходит готовым\nпо обмену"),
("2. Ссылка и SMS", "ссылка создаётся\nсама, SMS уходит"),
("3. Пауза 90 минут", "после SMS или\nпосле попытки, если\nSMS не ушло"),
("4. Звонок робота", "если клиент не\nподтвердил по ссылке"),
("5. Результат в заказ", "дата и половина дня\nв самом заказе"),
],
"full": {"title": "Важно", "accent": WARN, "items": [
"Готовность заказа приходит из 1С по обмену: заказ приходит уже готовым, вручную в системе её никто не отмечает",
"Если клиент подтвердил день по ссылке сам — звонка не будет: заказ уже согласован",
"Звонок идёт только в рабочее время и только в рабочие дни (настройки кампании)",
]},
},
{
"kind": "cards2full",
"title": "Откуда берутся дни и время",
"sub": "Админка → Расписание → Рабочие дни: один источник правды",
"cards": [
{"title": "Что настраивается", "accent": ACCENT, "items": [
"Рабочие дни склада (самовывоз) и рабочие дни доставки",
"Часы: открытие и закрытие склада, часы доставки",
"«Первая половина — до» — граница между половинами дня",
"«Сегодня предлагаем до» — до какого времени предлагать сегодняшний день",
]},
{"title": "Как это работает в коде", "accent": ACCENT2, "items": [
"Окно звонка считается рабочими днями: выходные не сокращают окно, а пропускаются",
"Робот называет реальные дни голосом: «сегодня», «в понедельник», «во вторник»",
"Клиентская ссылка использует те же дни — расхождений между звонком и ссылкой нет",
]},
],
"full": {"title": "Текущие значения в системе", "accent": WARN, "items": [
"Склад: пн–пт, 09:00–17:30, половина дня до 13:00, «сегодня» предлагаем до 15:30",
"Доставка: пн–пт (суббота — выходной) 09:00–21:00, половина дня — граница 13:00",
"Граница половины дня — 13:00 для обоих каналов: звоним после 13:00 — говорим про вторую половину дня, первую уже не предлагаем",
]},
},
{
"kind": "tablecard",
"title": "Самовывоз: какие дни предлагает",
"sub": "Окно — «день готовности + два рабочих дня», выходные пропускаются",
"table": {
"rows": [
["Заказ готов", "Что предложит робот", "Почему так"],
["в понедельник", "пн, вт, ср", "три рабочих дня подряд"],
["в пятницу", "пт, пн, вт", "суббота и воскресенье — выходные склада"],
["в пятницу после 15:30", "пн, вт", "«сегодня» больше не предлагаем (настройка)"],
["в понедельник после 13:00", "пн, вт, ср", "половину дня не спрашиваем — сразу вторая"],
["заказ готов давно", "звонка нет", "окно самовывоза прошло — звонить не о чем"],
],
"widths": [200, 240, 428],
},
"full": {"title": "Две настройки, которые это двигают", "accent": ACCENT, "items": [
"«Сегодня предлагаем до» (сейчас 15:30) — позже этого времени сегодняшний день исчезает из звонка и из ссылки",
"Граница половины дня (13:00): звоним после 13:00 — робот говорит про вторую половину дня, первую не предлагает",
]},
},
{
"kind": "tablecard",
"title": "Доставка: не в день готовности",
"sub": "Нужен запас времени, чтобы собрать маршрут — поэтому начинаем со следующего дня",
"table": {
"rows": [
["Заказ готов", "Что предложит робот"],
["в понедельник", "вторник и среда"],
["в пятницу", "понедельник и вторник (в субботу не возим)"],
["в четверг, звонок в пятницу", "пятница и понедельник"],
["в пятницу, звонок в понедельник", "понедельник и вторник"],
],
"widths": [300, 568],
},
"full": {"title": "Ключевое отличие от самовывоза", "accent": ACCENT2, "items": [
"В день готовности доставку не предлагаем никогда — только следующие рабочие дни",
"Суббота и воскресенье в графике доставки — выходные, их робот не назовёт",
"Половина дня — граница 13:00: звоним после 13:00, значит говорим про вторую половину дня",
]},
},
{
"kind": "tablecard",
"title": "Как проходит разговор",
"sub": "Робот задаёт вопрос и ждёт цифру; молчание не считается согласием",
"table": {
"rows": [
["Шаг", "Что говорит робот", "Что делает клиент", "Сколько ждёт"],
["Приветствие", "«Ваш заказ готов. Выберите день и время доставки»", "слушает", "—"],
["День", "«Нажмите один, чтобы доставить. В понедельник. Нажмите два…»", "нажимает 1 или 2", "9 секунд"],
["Половина дня", "«Первая половина дня или вторая?»", "нажимает 1 или 2", "9 секунд"],
["Подтверждение", "«Первая половина дня» — озвучивает выбор", "слушает", "—"],
["Изменить?", "подсказка про кнопку 2 — один раз", "нажимает 2, чтобы переделать", "6 секунд"],
],
"widths": [130, 360, 200, 178],
},
"full": {"title": "Что делает при ошибке ввода", "accent": WARN, "items": [
"Не понял цифру — повторяет тот же вопрос один раз",
"Снова не понял или молчит — завершает: «Вы не выбрали время. С вами свяжется менеджер»",
"Клиент нажимает 2 — диалог начинается заново («день и время»), прежний слот при этом не теряется",
]},
},
{
"kind": "tablecard",
"title": "Что можно нажать",
"sub": "Всего несколько цифр — и одна из них «больше не звоните»",
"table": {
"rows": [
["Цифра", "Значение", "Что происходит"],
["1", "первый предложенный день", "робот переходит к выбору половины дня"],
["2", "второй предложенный день", "робот переходит к выбору половины дня"],
["3", "третий день (только самовывоз)", "робот переходит к выбору половины дня"],
["1 / 2", "половина дня", "первая половина / вторая (после 13:00 — только вторая)"],
["2 после подтверждения", "изменить выбор", "робот спрашивает день и время заново — один раз"],
["любая другая цифра", "не принимается", "вопрос повторяется один раз; после двух таких звонков — менеджеру"],
],
"widths": [176, 200, 492],
},
"full": {"title": "Что происходит после подтверждения", "accent": ACCENT2, "items": [
"Робот называет выбранный день и половину дня вслух — клиент слышит, что именно записано",
"Дальше один раз предлагает изменить выбор: нажатие 2 — и день со временем спрашивают заново",
"Если клиент ничего не меняет, разговор завершается, а выбор уходит в заказ",
]},
},
{
"kind": "cards2full",
"title": "Если не дозвонились",
"sub": "Робот различает «не взял трубку» и «вместо человека ответил автоответчик/оператор»",
"cards": [
{"title": "Как понимает, что трубку не взяли", "accent": WARN, "items": [
"Никто не ответил за 45 секунд — попытка считается неудачной",
"Трубку подняли, но гудки шли больше 20 секунд и ни одной цифры не нажали — это объявление оператора, а не человек: пишем «не взял трубку»",
"Живой человек почти всегда берёт трубку раньше, поэтому правило надёжно отделяет автоответчик",
]},
{"title": "Что происходит дальше", "accent": ACCENT, "items": [
"Повторный звонок — не раньше чем через час (настройка)",
"Всего не больше 5 попыток на заказ (настройка)",
"Звонит только в рабочие часы кампании: 09:00–20:00, дни — по настройке",
"Исчерпали попытки — заказ уходит менеджеру, автозвонок по нему останавливается",
]},
],
"full": {"title": "Для менеджера это видно в карточке", "accent": ACCENT2, "items": [
"Каждая попытка — отдельная строка истории: время, результат, длительность разговора",
"Статус заказа меняется на «нужен менеджер» — с комментарием «не дозвонились / не выбрал»",
"Отдельно красным предупреждаем, если заявка «Позвонить сейчас» висит больше 15 минут",
]},
},
{
"kind": "tablecard",
"title": "Если сбросили трубку в середине разговора",
"sub": "Робот запоминает то, что успел услышать, и не портит уже согласованный слот",
"table": {
"rows": [
["Когда положили трубку", "Что запишет робот", "Что увидит менеджер"],
["до выбора дня", "ответил, день не выбрал", "«взял, не выбрал день» — попытка зачтена"],
["день выбрал, время не успел", "день выбран, время нет", "«взял, не выбрал время»"],
["выбор подтвердил и сбросил", "слот согласован", "дата и половина дня в заказе, статус «согласовано»"],
["начал менять выбор и сбросил", "остаётся прежний слот", "«правка не доведена» — прежнее согласование в силе"],
],
"widths": [250, 250, 368],
},
"full": {"title": "Почему так", "accent": ACCENT2, "items": [
"Слот становится согласованным только после двух ответов: день и половина дня — это защита от «услышал половину фразы»",
"Обрыв не считается согласием: если клиент не подтвердил, робот перезвонит по расписанию или отдаст заказ менеджеру",
"Если клиент уже согласовал слот, а потом начал менять и бросил трубку — прежний выбор сохраняется, ничего не теряется",
]},
},
{
"kind": "tablecard",
"title": "Клиент не выбрал дату после приглашения",
"sub": "Срок ожидания считаем от последнего контакта — SMS или звонка робота",
"table": {
"rows": [
["Что настроено в системе", "От чего считаем срок", "Когда заказ уходит менеджеру"],
["Только SMS-приглашение", "от последнего SMS", "через 3 ч (настройка в админке)"],
["Плюс автодозвон", "ждём звонок робота, отсчёт — от него", "через 3 ч после последнего звонка"],
["Плюс второе приглашение", "сначала ждём второе SMS, потом звонок", "после последнего контакта по последнему каналу"],
["Звонить нельзя (гео, исключение, «не звонить»)", "от последнего SMS", "через 3 ч — звонок не ждём"],
],
"widths": [250, 300, 318],
},
"full": {"title": "Что важно", "accent": ACCENT2, "items": [
"Ожидание включается только по каналам, которые реально включены в системе: если автодозвона нет — не ждём звонок",
"Позвонили позже — ожидание начинается заново: менеджер получает заказ, только когда клиент молчит по всем каналам",
"Клиент выбрал дату — из ожидания выходим сразу, даже если срок не истёк",
]},
},
{
"kind": "cards3full",
"title": "Когда подключается менеджер",
"sub": "Робот доводит дело до конца сам, но в трёх случаях передаёт человеку",
"cards": [
{"title": "Не выбрал день/время 2 раза", "accent": WARN, "items": [
"Робот дважды фиксировал «взял, но не выбрал»",
"Заказ помечается «нужен менеджер», комментарий «не дозвонились / не выбрал»",
"Порог «не выбрал × N» настраивается в админке автодозвона",
]},
{"title": "Исчерпаны 5 попыток", "accent": WARN, "items": [
"Каждый звонок — отдельная попытка",
"Когда попытки закончились, автозвонок прекращается",
"Заказ остаётся в списке у менеджера",
]},
{"title": "Неверный номер", "accent": DANGER, "items": [
"Если номер не существует или недоступен — задача менеджеру",
"Менеджер может поставить «Не звонить» — тогда робот этого клиента не беспокоит",
"Такая отметка видна в карточке заказа и учитывается при обзвоне",
]},
],
"full": {"title": "Что видит менеджер в карточке заказа", "accent": ACCENT, "items": [
"Единый статус автодозвона и история всех попыток с результатами и слотами",
"Кнопка «Позвонить сейчас» — ручной звонок клиенту (не тестовый номер) и живое состояние: «в очереди», «звоним», «звонок сделан» с результатом",
"Кнопка «Не звонить» — если робот не должен беспокоить этого клиента",
]},
},
{
"kind": "cards2full",
"title": "Когда робот не звонит вообще",
"sub": "Список условий, при которых звонка не будет — проверяются до набора номера",
"cards": [
{"title": "По клиенту и заказу", "accent": ACCENT, "items": [
"Клиент сам подтвердил день по ссылке — звонок не нужен",
"Менеджер поставил «Не звонить» — робот этого клиента не беспокоит",
"Номер в списке исключений — например, на МТС-диапазон звонки не идут",
"Самовывоз: автообзвон по самовывозу включается отдельной галочкой (сейчас выключен)",
]},
{"title": "По времени и географии", "accent": WARN, "items": [
"Вне рабочих часов кампании (09:00–20:00) и вне рабочих дней",
"Город исключён целиком или для города выключен канал",
"В админке автодозвона выключены звонки по городу — например, Ялта: SMS идут, звонков нет",
"Меньше 90 минут прошло после последней попытки отправки SMS: если приглашение ушло — считаем от отправки, если не ушло — от попытки",
]},
],
"full": {"title": "Админка автодозвона: где это переключается", "accent": ACCENT2, "items": [
"Панель «Автодозвон»: тестовый режим, часы и дни звонков, паузы, лимит попыток, порог «не выбрал × N»",
"Панель «Локации»: для каждого города — звонки включены или выключены (SMS не затрагивается)",
"Панель исключений: номера и префиксы, на которые не звоним, с причиной",
]},
},
{
"kind": "tablecard",
"title": "Что записывается в заказ",
"sub": "Результат звонка виден там же, где всё остальное",
"table": {
"rows": [
["Поле", "Значение"],
["Дата", "конкретная дата, которую назвал робот (например, понедельник 21.09)"],
["Время", "«первая половина дня» или «вторая половина дня»"],
["Источник", "«согласовано роботом» — видно, что выбор сделал клиент в звонке"],
["Статус", "«согласовано» / «нужен менеджер» / «не звонить» (запрет ставит менеджер)"],
["Комментарий", "«Согласовано роботом: завтра, первая половина дня»"],
],
"widths": [200, 668],
},
"full": {"title": "Зачем это нужно", "accent": ACCENT, "items": [
"Менеджер видит причину и источник решения, а не просто «дата стоит»",
"История звонков хранится отдельно: видно, сколько было попыток и чем они закончились",
"Дата, названная роботом, используется дальше в работе склада и доставки",
]},
},
{
"kind": "tablecard",
"title": "Тестовый режим и текущее состояние",
"sub": "Сейчас обзвон клиентов выключен — система в тестовом режиме",
"table": {
"rows": [
["Параметр", "Значение сейчас", "Что будет при запуске"],
["Обзвон клиентов", "выключен", "включается отдельной командой"],
["Тестовый номер", "звонит только на него", "массовый обзвон идёт клиентам"],
["Кнопка «Позвонить сейчас»", "звонит клиенту сразу", "так же — звонок клиенту"],
["Звонки по самовывозу", "выключены галочкой", "включаются отдельно от доставки"],
],
"widths": [230, 280, 358],
},
"full": {"title": "Как проверяется перед запуском", "accent": ACCENT2, "items": [
"Тестовый звонок на свой номер: диалог, дни, половина дня, запись результата",
"Прогон диалога без звонка: проверка всех веток (согласие, обрыв, молчание, отказ)",
"Проверка окон: робот предлагает именно те дни, которые заданы в админке",
]},
},
{
"kind": "cards2full",
"title": "Что пока не учитывается",
"sub": "Честный список: это важно знать до запуска",
"cards": [
{"title": "График", "accent": WARN, "items": [
"Праздники: календаря нерабочих дат нет — если праздник попадёт в окно, робот его предложит",
"Часы доставки используются частично: границу половины дня робот проговаривает словами, без конкретного времени",
]},
{"title": "Настройки, которые пока не влияют", "accent": DANGER, "items": [
"«Попыток в день» — поле есть, но расписание повторов задаётся интервалом между попытками",
"«Попытки следующего дня (10:00, 14:00)» — пока не используются",
"«Таймаут звонка, 30 секунд» — звонок ждёт ответа 45 секунд (значение в самом звонке)",
]},
],
"full": {"title": "Что предлагаем уточнить до боевого запуска", "accent": ACCENT2, "items": [
"Список праздников (или решение не учитывать их)",
"Нужен ли жёсткий график повторов по времени, кроме паузы в час",
"Кто и когда разбирает заказы, которые робот не согласовал за 5 попыток",
]},
},
{
"kind": "cards2full",
"title": "Коротко",
"sub": "Одна страница вместо всей презентации",
"cards": [
{"title": "Что делает система", "accent": ACCENT, "full": True, "dense": True, "items": [
"Ждёт 90 минут после SMS (или после последней попытки отправки, если SMS не ушло) и звонит, если клиент не подтвердил день сам",
"Предлагает только рабочие дни по графику: самовывоз — включая сегодня, доставка — со следующего дня",
"Спрашивает день и половину дня, подтверждает вслух, даёт один раз переделать выбор",
"Граница половины дня — 13:00: звоним после 13:00, значит говорим про вторую половину",
"Повторяет попытки не чаще чем раз в час, максимум 5 раз, только 09:00–20:00 в рабочие дни",
"После двух «взял, но не выбрал» или исчерпания попыток передаёт заказ менеджеру",
"Каждая попытка и результат видны в карточке заказа; ручной звонок идёт клиенту и показывает своё состояние",
]},
],
"full": {"title": "Что осталось решить", "accent": WARN, "items": [
"Праздники и точный график повторов — по вашим правилам",
"Момент включения боевого обзвона клиентов и по самовывозу",
]},
},
]
ALL_SLIDES = [TITLE_SLIDE] + SLIDES

View File

@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""PDF-версия презентации «Автодозвон: как это работает» (тёмная тема, 16:9).
Содержание — из deck_content.py (то же, что в HTML-версии), здесь только вёрстка.
Текст, который не влезает в карточку, считается ошибкой: скрипт падает, а не рисует
поверх границ.
Запуск: python3 docs/autocall/how-it-works.py
"""
from pathlib import Path
from reportlab.lib.colors import Color, HexColor
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
import deck_content as content
FONT_DIR = Path("/usr/share/fonts/truetype/dejavu")
OUT = Path(__file__).with_name("autocall-how-it-works.pdf")
W, H = 960, 540
M = 46
BG = HexColor("#0B1220")
BG2 = HexColor("#101A2C")
CARD = HexColor("#131E33")
BORDER = HexColor("#25344F")
TEXT = HexColor("#E8EEF9")
DIM = HexColor("#9FB0CC")
TONES = {
"accent": HexColor("#4ADE80"),
"accent2": HexColor("#60A5FA"),
"warn": HexColor("#FBBF24"),
"danger": HexColor("#F87171"),
}
ACCENT = TONES["accent"]
pdfmetrics.registerFont(TTFont("DV", str(FONT_DIR / "DejaVuSans.ttf")))
pdfmetrics.registerFont(TTFont("DV-B", str(FONT_DIR / "DejaVuSans-Bold.ttf")))
c = canvas.Canvas(str(OUT), pagesize=(W, H))
c.setTitle("Автодозвон SuperSam — как это работает")
c.setAuthor("SuperSam")
slide_no = 0
CONTENT_W = W - 2 * M
def tone(name):
return TONES.get(name, ACCENT)
def wrap(text, font, size, max_w):
words, lines, cur = text.split(), [], ""
for word in words:
probe = f"{cur} {word}".strip()
if pdfmetrics.stringWidth(probe, font, size) <= max_w:
cur = probe
else:
if cur:
lines.append(cur)
cur = word
if cur:
lines.append(cur)
return lines
def bg():
c.setFillColor(BG)
c.rect(0, 0, W, H, stroke=0, fill=1)
c.setFillColor(BG2)
c.rect(0, H - 120, W, 120, stroke=0, fill=1)
c.setFillColor(Color(0.29, 0.85, 0.5, alpha=0.06))
c.circle(W - 90, H - 40, 150, stroke=0, fill=1)
def footer():
global slide_no
slide_no += 1
c.setFont("DV", 9)
c.setFillColor(DIM)
c.drawString(M, 22, "Автодозвон SuperSam · как это работает")
c.drawRightString(W - M, 22, f"{slide_no}")
c.setStrokeColor(BORDER)
c.setLineWidth(0.5)
c.line(M, 36, W - M, 36)
def header(title, sub=None, kicker=None):
y = H - 60
if kicker:
c.setFont("DV-B", 10)
c.setFillColor(ACCENT)
c.drawString(M, y + 22, kicker.upper())
c.setFont("DV-B", 26)
c.setFillColor(TEXT)
c.drawString(M, y - 12, title)
if sub:
c.setFont("DV", 12)
c.setFillColor(DIM)
c.drawString(M, y - 34, sub)
def card(x, y, w, h, accent=None, fill=CARD):
c.setFillColor(fill)
c.setStrokeColor(accent or BORDER)
c.setLineWidth(1)
c.roundRect(x, y, w, h, 12, stroke=1, fill=1)
def block_size(items, w, size=12, lead=18, title_size=14):
"""Сколько места займёт карточка: список строк и высота."""
wrapped = [wrap(item, "DV", size, w - 44) for item in items]
lines = sum(len(w_) for w_ in wrapped)
height = 50 + lines * lead + len(items) * 4
return wrapped, height
def draw_block(x, y, w, h, title, items, accent=ACCENT, size=12, lead=18, title_size=14):
wrapped, _ = block_size(items, w, size, lead, title_size)
card(x, y - h, w, h, accent=accent)
c.setFont("DV-B", title_size)
c.setFillColor(accent)
c.drawString(x + 16, y - 26, title)
cursor = y - 48
for wrapped_item in wrapped:
for i, line in enumerate(wrapped_item):
if i == 0:
c.setFillColor(accent)
c.circle(x + 20, cursor + 4, 2.4, stroke=0, fill=1)
c.setFont("DV", size)
c.setFillColor(TEXT)
c.drawString(x + 32, cursor, line)
cursor -= lead
cursor -= 4
if cursor < (y - h) - 2:
raise AssertionError(f"текст вышел за карточку «{title}»")
return y - h
def table_measure(rows, widths, size=11, lead=19):
"""Высота таблицы — чтобы можно было отцентровать блок по слайду."""
h = 0
for row in rows:
lines = max(len(wrap(str(cell), "DV", size, cw - 16)) for cell, cw in zip(row, widths))
h += lead + 8 + (lines - 1) * (lead - 4)
return h
def cards_row(cards, y, total_w=CONTENT_W, gap=14):
"""Ряд карточек одинаковой высоты; возвращает нижнюю границу ряда."""
n = len(cards)
w = (total_w - gap * (n - 1)) / n
heights = [block_size(card_["items"], w)[1] for card_ in cards]
h = max(heights)
x = M
for card_ in cards:
draw_block(x, y, w, h, card_["title"], card_["items"], accent=tone(card_.get("accent")))
x += w + gap
return y - h
def table(rows, widths, y, size=11, lead=19, head=True):
"""Таблица; возвращает нижнюю границу."""
for r_i, row in enumerate(rows):
lines_per_cell = [wrap(str(cell), "DV", size, cw - 16) for cell, cw in zip(row, widths)]
h = lead + 8 + (max(len(l) for l in lines_per_cell) - 1) * (lead - 4)
if head and r_i == 0:
card(M, y - h, CONTENT_W, h, accent=TONES["accent2"], fill=HexColor("#182640"))
cx = M
for (cell, cw), cell_lines in zip(zip(row, widths), lines_per_cell):
if head and r_i == 0:
cx += cw
continue
for i, line in enumerate(cell_lines):
c.setFont("DV", size)
c.setFillColor(DIM if (i == 0 and cx == M) else TEXT)
c.drawString(cx + 8, y - 21 - i * (lead - 4), line)
cx += cw
if head and r_i == 0:
cx = M
for cell, cw in zip(row, widths):
c.setFont("DV-B", size)
c.setFillColor(TEXT)
c.drawString(cx + 8, y - 21, str(cell))
cx += cw
y -= h
return y
def steps_row(steps, y):
n = len(steps)
gap = 14
w = (CONTENT_W - gap * (n - 1)) / n
h = 170
x = M
for i, (title, desc) in enumerate(steps):
accent = ACCENT if i < n - 1 else TONES["accent2"]
card(x, y - h, w, h, accent=accent)
c.setFont("DV-B", 12)
c.setFillColor(accent)
c.drawString(x + 14, y - 34, title)
c.setFont("DV", 11)
c.setFillColor(TEXT)
line_y = y - 58
for raw_line in desc.split("\n"):
for line in wrap(raw_line, "DV", 11, w - 28):
c.drawString(x + 14, line_y, line)
line_y -= 17
if i < n - 1:
c.setFillColor(DIM)
c.setFont("DV-B", 16)
c.drawString(x + w + 1, y - 72, "›")
x += w + gap
return y - h
def render(slide):
bg()
if slide["kind"] == "title":
c.setFont("DV-B", 11)
c.setFillColor(ACCENT)
c.drawString(M, H - 150, slide["kicker"])
c.setFont("DV-B", 40)
c.setFillColor(TEXT)
c.drawString(M, H - 210, slide["title"])
c.setFont("DV", 15)
c.setFillColor(DIM)
c.drawString(M, H - 244, slide["sub"])
card(M, 120, CONTENT_W, 150, accent=ACCENT)
for i, line in enumerate(slide["lines"]):
c.setFont("DV", 13)
c.setFillColor(TEXT if line else DIM)
c.drawString(M + 18, 240 - i * 20, line)
footer()
c.showPage()
return
header(slide["title"], slide.get("sub"))
kind = slide["kind"]
full = slide.get("full")
# Измеряем содержимое и центруем блок по вертикали: иначе внизу слайда
# остаётся пустая полоса, а на другом слайде текст поджимает футер.
if kind == "steps":
main_h = 170
elif kind in ("cards2full", "cards3full"):
n = len(slide["cards"])
w = (CONTENT_W - 14 * (n - 1)) / n
main_h = max(block_size(card_["items"], w)[1] for card_ in slide["cards"])
elif kind == "tablecard":
main_h = table_measure(slide["table"]["rows"], slide["table"]["widths"])
else:
raise AssertionError(f"неизвестный тип слайда: {kind}")
full_h = block_size(full["items"], CONTENT_W)[1] if full else 0
gap = 22 if full else 0
top, bottom_limit = H - 96, 56
total = main_h + (gap + full_h if full else 0)
y0 = top - max(0.0, (top - bottom_limit - total) / 2)
if kind == "steps":
bottom = steps_row(slide["steps"], y0)
elif kind in ("cards2full", "cards3full"):
bottom = cards_row(slide["cards"], y0)
else:
bottom = table(slide["table"]["rows"], slide["table"]["widths"], y0)
if full:
y = bottom - gap
if y - full_h < 44:
raise AssertionError(f"нижняя карточка не влезает на слайде «{slide['title']}»")
draw_block(M, y, CONTENT_W, full_h, full["title"], full["items"], accent=tone(full.get("accent")))
footer()
c.showPage()
for slide_ in content.ALL_SLIDES:
render(slide_)
c.save()
print(f"готово: {OUT} — слайдов {slide_no}")
print(f"вёрстка проверена: все карточки по размеру текста (проверено {slide_no} слайдов)")

View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""HTML-версия презентации «Автодозвон: как это работает» (тот же текст, что в PDF).
Содержание берётся из deck_content.py, поэтому PDF и HTML не разъезжаются.
Файл самодостаточный: без внешних шрифтов, картинок и скриптов — открывается
двойным щелчком и с телефона, и печатается в PDF по одному слайду на страницу.
Запуск: python3 docs/autocall/render-html.py
"""
from html import escape
from pathlib import Path
import deck_content as content
OUT = Path(__file__).with_name("autocall-how-it-works.html")
TONE = {
"accent": "#4ADE80",
"accent2": "#60A5FA",
"warn": "#FBBF24",
"danger": "#F87171",
}
def esc(text):
return escape(str(text))
def card_html(card, extra_class=""):
tone = TONE.get(card.get("accent", "accent"), TONE["accent"])
items = "".join(f"<li>{esc(i)}</li>" for i in card["items"])
classes = " ".join(f"card {extra_class} {'dense' if card.get('dense') else ''}".split())
return (
f'<section class="{classes}" style="--tone:{tone}">'
f'<h3>{esc(card["title"])}</h3><ul>{items}</ul></section>'
)
def table_html(table):
head, *body = table["rows"]
widths = table.get("widths") or []
colgroup = "".join(f'<col style="width:{w / sum(widths) * 100:.2f}%">' for w in widths) if widths else ""
head_html = "".join(f"<th>{esc(c)}</th>" for c in head)
body_html = "".join(
"<tr>" + "".join(f"<td>{esc(c)}</td>" for c in row) + "</tr>" for row in body
)
return (
f'<table>{"<colgroup>" + colgroup + "</colgroup>" if colgroup else ""}'
f"<thead><tr>{head_html}</tr></thead><tbody>{body_html}</tbody></table>"
)
def steps_html(steps):
out = []
for i, (title, desc) in enumerate(steps):
lines = "".join(f"<span>{esc(line)}</span>" for line in desc.split("\n"))
arrow = '<div class="arrow">›</div>' if i < len(steps) - 1 else ""
out.append(f'<div class="step"><b>{esc(title)}</b>{lines}{arrow}</div>')
return f'<div class="steps">{"".join(out)}</div>'
def slide_html(slide, number):
kind = slide["kind"]
parts = ['<footer><span>Автодозвон SuperSam · как это работает</span>'
f'<span>{number}</span></footer>']
if kind == "title":
lines = "".join(f"<p>{esc(line)}</p>" if line else "<p>&nbsp;</p>" for line in slide["lines"])
body = (
f'<div class="kicker">{esc(slide["kicker"])}</div>'
f'<h1>{esc(slide["title"])}</h1>'
f'<p class="sub">{esc(slide["sub"])}</p>'
f'<div class="card titlecard" style="--tone:{TONE["accent"]}">{lines}</div>'
)
else:
head = f'<h2>{esc(slide["title"])}</h2><p class="sub">{esc(slide["sub"])}</p>'
body = head
if kind == "steps":
body += steps_html(slide["steps"])
elif kind in ("cards2full", "cards3full"):
cards = "".join(card_html(c) for c in slide["cards"])
body += f'<div class="grid {kind}">{cards}</div>'
elif kind == "tablecard":
body += table_html(slide["table"])
full = slide.get("full")
if full:
body += card_html(full, extra_class="full")
return f'<section class="slide {kind}">{body}{parts[0]}</section>'
def build():
slides = "".join(slide_html(s, i + 1) for i, s in enumerate(content.ALL_SLIDES))
html = f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Автодозвон SuperSam — как это работает</title>
<style>
:root {{
--bg: #0B1220; --bg2: #101A2C; --card: #131E33; --border: #25344F;
--text: #E8EEF9; --dim: #9FB0CC; --accent: {TONE['accent']};
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0; background: var(--bg); color: var(--text);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex; flex-direction: column; align-items: center; gap: 24px; padding: 24px 12px 48px;
}}
.slide {{
position: relative; width: min(1180px, 100%); aspect-ratio: 16 / 9; overflow: hidden;
background: linear-gradient(180deg, var(--bg2) 0%, var(--bg) 34%);
border: 1px solid var(--border); border-radius: 18px;
padding: 36px 40px 46px; display: flex; flex-direction: column; gap: 14px;
}}
.kicker {{ color: var(--accent); font-weight: 700; letter-spacing: .08em; font-size: 13px; text-transform: uppercase; }}
h1 {{ font-size: 40px; line-height: 1.15; margin: 6px 0 0; }}
h2 {{ font-size: 26px; margin: 0; }}
h3 {{ color: var(--tone, var(--accent)); font-size: 15px; margin: 0 0 8px; }}
.sub {{ color: var(--dim); margin: 4px 0 6px; font-size: 15px; }}
.grid {{ display: grid; gap: 14px; }}
.grid.cards2full {{ grid-template-columns: 1fr 1fr; }}
.grid.cards3full {{ grid-template-columns: repeat(3, 1fr); }}
.card {{
background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--tone, var(--accent));
border-radius: 12px; padding: 14px 16px;
}}
.card.full {{ grid-column: 1 / -1; }}
.card ul {{ margin: 0; padding-left: 18px; }}
.card li {{ margin: 5px 0; font-size: 14.5px; color: var(--text); }}
.card li::marker {{ color: var(--tone, var(--accent)); }}
.card.dense ul {{ column-count: 2; column-gap: 28px; }}
.titlecard p {{ margin: 6px 0; color: var(--text); }}
.steps {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }}
.step {{
position: relative; background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
}}
.step b {{ color: var(--accent); font-size: 13.5px; }}
.step span {{ color: var(--text); font-size: 13px; }}
.arrow {{ position: absolute; right: -11px; top: 50%; transform: translateY(-50%); color: var(--dim); font-size: 20px; }}
table {{ width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }}
thead th {{ background: #182640; color: var(--text); text-align: left; font-size: 13.5px; }}
th, td {{ padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 13.5px; vertical-align: top; }}
tbody tr:nth-child(even) td {{ background: rgba(255,255,255,.015); }}
th:first-child, td:first-child {{ color: var(--dim); }}
footer {{
position: absolute; left: 40px; right: 40px; bottom: 14px; display: flex; justify-content: space-between;
color: var(--dim); font-size: 11.5px; border-top: 1px solid var(--border); padding-top: 8px;
}}
@media (max-width: 1200px) {{
/* Узкое окно: фиксированная 16:9 начинает резать текст — даём слайду расти */
.slide {{ aspect-ratio: auto; }}
}}
@media (max-width: 900px) {{
.grid.cards2full, .grid.cards3full, .steps {{ grid-template-columns: 1fr; }}
.card.dense ul {{ column-count: 1; }}
.arrow {{ display: none; }}
h2 {{ font-size: 22px; }}
}}
@media (max-width: 760px) {{
body {{ padding: 12px 8px 32px; }}
.slide {{ aspect-ratio: auto; padding: 22px 18px 52px; }}
h1 {{ font-size: 26px; }} h2 {{ font-size: 20px; }}
.grid.cards2full, .grid.cards3full, .steps {{ grid-template-columns: 1fr; }}
.card.dense ul {{ column-count: 1; }}
.arrow {{ display: none; }}
th, td {{ font-size: 12px; padding: 7px 8px; }}
.card li {{ font-size: 13.5px; }}
}}
@media print {{
body {{ background: #fff; padding: 0; gap: 0; }}
.slide {{ width: 100%; height: 100vh; border-radius: 0; border: 0; page-break-after: always; }}
}}
</style>
</head>
<body>
{slides}
</body>
</html>
"""
OUT.write_text(html, encoding="utf-8")
print(f"готово: {OUT} — слайдов {len(content.ALL_SLIDES)}")
if __name__ == "__main__":
build()

View File

@ -0,0 +1,55 @@
-- Разделяем «не выбрал день/время» на три честных состояния:
-- no_input_day — ответил, день не выбрал
-- no_input_half — день выбрал, время не успел
-- no_input_both — в разных звонках не выбрал и день, и время
-- Значения совпадают с AUTOCALL_STATUS_META / AUTOCALL_STATUS_OPTIONS во фронте
-- (правило: функция в БД + бейдж + фильтр меняются вместе).
CREATE OR REPLACE FUNCTION public.autocall_status(og order_groups)
RETURNS text
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
select case
when og.autocall_opt_out then 'opt_out'
when exists (
select 1 from public.autocall_exclusions e
where e.active
and (
(e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value)
or
(e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%')
)
) then 'excluded'
when not exists (select 1 from public.autocall_log l where l.order_group_id = og.id) then 'not_called'
when exists (
select 1 from public.autocall_log l
where l.order_group_id = og.id
and l.outcome in ('confirmed','confirmed_silent','confirmed_kept_previous')
) then 'confirmed'
else (
select case
when l.outcome = 'no_answer' then 'no_answer'
when l.outcome in ('no_input_day','no_input_half') then
case
when exists (select 1 from public.autocall_log l2
where l2.order_group_id = og.id and l2.outcome = 'no_input_day')
and exists (select 1 from public.autocall_log l3
where l3.order_group_id = og.id and l3.outcome = 'no_input_half')
then 'no_input_both'
when l.outcome = 'no_input_half' then 'no_input_half'
else 'no_input_day'
end
when l.outcome in ('operator','error','invalid_number') then 'manual'
else 'not_called'
end
from public.autocall_log l
where l.order_group_id = og.id
order by l.created_at desc
limit 1
)
end;
$function$;
-- Проверка: сколько заказов в каждом состоянии
-- select public.autocall_status(og), count(*) from public.order_groups og group by 1 order by 2 desc;

86
no8-logic.js Normal file
View File

@ -0,0 +1,86 @@
// n8n workflow: No8 — «Смена статуса на Доставлено (запрос из 1С)»
// Payload от 1С: { Value: "<секрет>", dost: "<номер заказа>" }
// Цель: найти order_group по номеру, delivery_status → delivered, лог-событие.
const NO8_SECRET = "energy7066-stood-Sight-nation569";
// helper вызова Supabase REST
async function supa(method, path, body) {
return this.helpers.request({
method,
url: `${"https://supa.supersamsev.ru"}/rest/v1${path}`,
headers: {
apikey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODc4Nzk4LCJleHAiOjE5MzI1NTg3OTh9.GAhB_V3js6KzpcBxoY0971-x6oR0WjYnj6wakvPPtIg",
Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0ODc4Nzk4LCJleHAiOjE5MzI1NTg3OTh9.GAhB_V3js6KzpcBxoY0971-x6oR0WjYnj6wakvPPtIg",
"Content-Type": "application/json",
Prefer: "return=minimal",
},
body: ["POST", "PATCH", "PUT"].includes(method) ? JSON.stringify(body) : undefined,
});
}
// === вход: $json.body из вебхука (уже пришёл из 1С) ===
const body = $json.body || {};
// === check secret ===
if (body.Value !== NO8_SECRET) {
return [{ json: { ok: false, error: "unauthorized" } },];
}
// === номер заказа ===
const dostRaw = String(body.dost || "").replace(/\D/g, "");
if (!dostRaw) {
return [{ json: { ok: false, error: "dost (номер заказа) не передан или не содержит цифр" } }];
}
const candidates = [`ТП-${dostRaw}`, dostRaw];
console.log("No8: ищем заказ:", candidates.join(", "));
// === 1) найти order_group ===
let orderGroup = null;
for (const num of candidates) {
const found = await supa.call(this, "GET",
`/order_groups?select=id,user_id,order_number,customer_phone,customer_phone_normalized,notification_status,delivery_type,delivery_status&order_number=eq.${encodeURIComponent(num)}&limit=1`);
if (Array.isArray(found) && found.length) {
orderGroup = found[0];
orderGroup.order_number = num;
break;
}
}
if (!orderGroup) {
return [{ json: { ok: false, error: `Заказ ${body.dost} не найден (пробовали ${candidates.join(" / ")})` } }];
}
console.log("No8: найден заказ:", orderGroup.order_number, "group.id:", orderGroup.id);
// === 2) обновить статус delivery_status='delivered' + called_at ===
const now = new Date().toISOString();
await supa.call(this, "PATCH", `/order_groups?id=eq.${orderGroup.id}`, {
delivery_status: "delivered",
called_at: now,
updated_at: now,
});
console.log("No8: выставлен delivered для group:", orderGroup.id);
// === 3) событие логистики (action_log) ===
try {
await supa.call(this, "POST", "/action_log", {
user_id: orderGroup.user_id || null,
order_group_id: orderGroup.id,
action_type: "delivered_by_robot",
comment: `Доставлено по сигналу No8 из 1С. Заказ ${orderGroup.order_number}`,
created_at: now,
});
console.log("No8: событие записано в action_log");
} catch (e) {
console.warn("No8: action_log не принял (может отсутствовать колонка):", e.message || e);
}
// === выход ===
return [{
json: {
ok: true,
order_group_id: orderGroup.id,
order_number: orderGroup.order_number,
delivery_status: "delivered",
},
}];

View File

@ -0,0 +1,10 @@
[Unit]
Description=SuperSam Autocall Scheduler
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/supersam/scripts/sms_run_with_db.sh /opt/supersam/scripts/autocall_scheduler.py
WorkingDirectory=/opt/supersam
TimeoutStartSec=120

View File

@ -69,36 +69,69 @@ def load_settings(conn):
"delay_after_sms_minutes": 90, "retry_interval_minutes": 120, "delay_after_sms_minutes": 90, "retry_interval_minutes": 120,
"attempts_per_day": 3, "max_attempts": 5, "attempts_per_day": 3, "max_attempts": 5,
"no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00", "no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00",
"call_timeout_seconds": 30, "call_timeout_seconds": 30, "pickup_calls_enabled": False,
} }
return dict(row) return dict(row)
def get_groups_to_call(conn, settings): def resolve_scenario(created_at, now=None):
"""Группы, которым пора звонить.""" """Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра),
delay_min = settings.get("delay_after_sms_minutes", 90) со следующего дня → B (день фиксирован = завтра, только половина дня)."""
retry_min = settings.get("retry_interval_minutes", 120) if not created_at:
max_attempts = settings.get("max_attempts", 5) return "A"
no_input_escalate = settings.get("no_input_escalate_after", 2) now = now or datetime.now(timezone.utc)
msk_tz = timezone(timedelta(hours=3))
created = created_at.astimezone(msk_tz)
current = now.astimezone(msk_tz)
return "A" if created.date() == current.date() else "B"
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" GROUPS_TO_CALL_SQL = """
SELECT og.id, og.customer_phone, og.customer_phone_normalized, SELECT og.id, og.customer_phone, og.customer_phone_normalized,
og.notification_status, og.delivery_status, og.sms_sent_at, og.notification_status, og.delivery_status, og.sms_sent_at,
og.autocall_opt_out, og.autocall_opt_out, og.created_at, og.delivery_type, og.town,
(SELECT count(*) FROM autocall_log l (SELECT count(*) FROM autocall_log l
WHERE l.order_group_id = og.id) as attempt_count, WHERE l.order_group_id = og.id) as attempt_count,
(SELECT count(*) FROM autocall_log l (SELECT count(*) FROM autocall_log l
WHERE l.order_group_id = og.id WHERE l.order_group_id = og.id
AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count, AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count,
(SELECT max(l.created_at) FROM autocall_log l (SELECT max(l.created_at) FROM autocall_log l
WHERE l.order_group_id = og.id) as last_call_at WHERE l.order_group_id = og.id) as last_call_at,
-- От чего считаем паузу перед звонком:
-- * SMS ушло — от времени отправки;
-- * SMS не ушло/не доставлено — от последней попытки отправки
-- (владелец: «если SMS не отправлено — звоним через 90 минут
-- после последней попытки»).
COALESCE(
CASE WHEN t.try_at IS NOT NULL
AND t.try_status NOT IN ('sent', 'delivered')
THEN t.try_at END,
og.sms_sent_at
) as call_after
FROM order_groups og FROM order_groups og
LEFT JOIN LATERAL (
SELECT COALESCE(l.updated_at, l.created_at) AS try_at, l.status AS try_status
FROM sms_campaign_log l
WHERE l.order_group_id = og.id
AND l.campaign_type IN ('first_sms', 'second_sms')
ORDER BY COALESCE(l.updated_at, l.created_at) DESC
LIMIT 1
) 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')
AND og.sms_sent_at IS NOT NULL -- Пауза после SMS (или после неудачной попытки) прошла
-- Пауза после SMS прошла AND COALESCE(
AND og.sms_sent_at < NOW() - make_interval(mins => %s) CASE WHEN t.try_at IS NOT NULL
AND t.try_status NOT IN ('sent', 'delivered')
THEN t.try_at END,
og.sms_sent_at
) IS NOT NULL
AND COALESCE(
CASE WHEN t.try_at IS NOT NULL
AND t.try_status NOT IN ('sent', 'delivered')
THEN t.try_at END,
og.sms_sent_at
) < NOW() - make_interval(mins => %s)
-- Не звонили или прошёл интервал повтора -- Не звонили или прошёл интервал повтора
AND ( AND (
NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id) NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id)
@ -108,8 +141,41 @@ def get_groups_to_call(conn, settings):
-- Не превышен лимит попыток -- Не превышен лимит попыток
AND (SELECT count(*) FROM autocall_log l AND (SELECT count(*) FROM autocall_log l
WHERE l.order_group_id = og.id) < %s WHERE l.order_group_id = og.id) < %s
ORDER BY og.sms_sent_at ASC -- Гео-исключения: те же, что у SMS (город исключён целиком
""", (delay_min, retry_min, max_attempts)) -- или канал для города выключен в профиле).
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'autocall_slot')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
AND NOT EXISTS (
SELECT 1 FROM sms_city_profiles p
WHERE p.city = og.town AND p.sms_enabled = false
)
-- Локация выключена для звонков в админке автодозвона
AND NOT EXISTS (
SELECT 1 FROM autocall_city_rules c
WHERE c.city = og.town AND c.calls_enabled = false
)
-- Самовывоз звоним только если разрешено настройкой
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
ORDER BY call_after ASC
"""
def get_groups_to_call(conn, settings):
"""Группы, которым пора звонить."""
delay_min = settings.get("delay_after_sms_minutes", 90)
retry_min = settings.get("retry_interval_minutes", 120)
max_attempts = settings.get("max_attempts", 5)
pickup_enabled = bool(settings.get("pickup_calls_enabled", False))
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(GROUPS_TO_CALL_SQL,
(delay_min, retry_min, max_attempts, pickup_enabled))
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def is_work_time(settings, now=None): def is_work_time(settings, now=None):
@ -136,8 +202,226 @@ def normalize_phone(phone):
clean = "7" + clean clean = "7" + clean
return clean return clean
def build_call_file(group, settings, attempt_no, is_test_call=False): def get_city_exclusion(conn, city):
"""Формирует содержимое call-файла.""" """Гео-исключение — общее для SMS и звонков.
Источник тот же, что у SMS: правила города (sms_city_rules) и общий
выключатель канала в профиле города (sms_city_profiles.sms_enabled).
Правило с campaign_type='*' действует на все каналы, 'autocall_slot' —
только на звонки.
Исключения по номеру (например, «не звонить на МТС», autocall_exclusions)
живут отдельно и на SMS не влияют — они про телефонию, а не про город.
"""
if not city:
return None
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT city, campaign_type, note
FROM sms_city_rules
WHERE city = %s
AND enabled = false
AND campaign_type IN ('*', 'autocall_slot')
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
LIMIT 1
""", (city,))
rule = cur.fetchone()
if rule:
return {
"source": "city_rule",
"reason": (rule.get("note") or "").strip() or f"город {city} исключён из рассылок",
}
cur.execute("SELECT sms_enabled, note FROM sms_city_profiles WHERE city = %s LIMIT 1", (city,))
profile = cur.fetchone()
if profile is not None and profile.get("sms_enabled") is False:
return {
"source": "city_profile",
"reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен",
}
# Только звонки: переключатель локации в админке автодозвона
# (на SMS не влияет — уведомления по городу продолжают уходить).
cur.execute("SELECT calls_enabled, note FROM autocall_city_rules WHERE city = %s LIMIT 1", (city,))
calls_rule = cur.fetchone()
if calls_rule is not None and calls_rule.get("calls_enabled") is False:
return {
"source": "city_calls_off",
"reason": (calls_rule.get("note") or "").strip() or f"звонки по {city} выключены в админке",
}
return None
def get_exclusion_match(conn, phone):
"""Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
phone = normalize_phone(phone)
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT rule_type, value, reason
FROM autocall_exclusions
WHERE active = true
AND (
(rule_type = 'phone' AND %s = value)
OR
(rule_type = 'prefix' AND %s LIKE value || '%%')
)
LIMIT 1
""", (phone, phone))
return cur.fetchone()
def load_work_days(conn, field="pickup_days"):
"""Рабочие дни по графику компании (1=пн … 7=вс).
График правит админ в «График работы» (business_schedule_settings):
`pickup_days` — дни склада, `delivery_days` — дни доставки. Звонки обязаны
опираться именно на эти данные: в пятницу «послезавтра» — это воскресенье,
когда ни самовывоз, ни доставка не работают.
"""
columns = {"pickup_days", "delivery_days"} # белый список: имя колонки идёт в SQL
column = field if field in columns else "pickup_days"
default = [1, 2, 3, 4, 5]
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(f"SELECT {column} AS days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone() or {}
raw = row.get("days") or default
days = sorted({int(str(d).strip()) for d in raw if str(d).strip().isdigit()})
return days or default
except Exception as e:
log.warning(f"Не удалось прочитать график работы ({column}), беру пн-пт: {e}")
return default
def load_work_hours(conn):
"""Рабочие часы склада и доставки из «Графика работы» (админка).
Возвращает минуты от полуночи — сравнивать часы строками нельзя.
Дефолты повторяют прежнее поведение кода: первая половина до 12:00,
«сегодня» предлагалось весь день.
"""
defaults = {
"pickup_half_end": 12 * 60,
"pickup_same_day_cutoff": 24 * 60,
"pickup_close": 18 * 60,
"delivery_half_end": 15 * 60,
"delivery_close": 21 * 60,
}
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT pickup_half_end, pickup_same_day_cutoff, pickup_close,
delivery_half_end, delivery_close
FROM business_schedule_settings WHERE id = 1
""")
row = cur.fetchone() or {}
hours = {}
for key, fallback in defaults.items():
value = row.get(key)
if value is None:
hours[key] = fallback
continue
hours[key] = value.hour * 60 + value.minute
return hours
except Exception as exc: # noqa: BLE001 — часы не критичны, звоним по дефолту
log.warning("Не удалось прочитать рабочие часы, беру значения по умолчанию: %s", exc)
return dict(defaults)
def work_day_window(start_date, work_days, count):
"""`count` ближайших рабочих дней начиная со `start_date` включительно.
Выходные и нерабочие дни не сокращают окно, а пропускаются.
"""
days = []
day = start_date
guard = 0
while len(days) < count and guard < 30:
if day.isoweekday() in work_days:
days.append(day)
day += timedelta(days=1)
guard += 1
return days
def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
"""Даты, на которые предлагаем самовывоз.
Правило владельца: окно считается **рабочими днями** — день готовности
плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают
окно, а пропускаются):
• готов в понедельник → пн, вт, ср
• готов в пятницу → пт, пн, вт (сб и вс склад закрыт)
• готов в субботу → пн, вт (сам выходной не предлагаем)
• готов вчера → остаток окна начиная с сегодня
• окно прошло → пусто, звонить не о чем
cutoff_minutes — время, после которого «сегодня» уже не предлагаем
(склад не успеет собрать заказ к выдаче, настройка админки).
"""
now = now or datetime.now(timezone.utc)
work_days = work_days or [1, 2, 3, 4, 5]
tz = timezone(timedelta(hours=3))
local_now = now.astimezone(tz)
today = local_now.date()
ready = (sms_sent_at or now).astimezone(tz).date()
window = work_day_window(ready, work_days, 3)
first_day = today
# Поздно для сегодняшнего забора — окно начинается со следующего дня.
if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes:
first_day = today + timedelta(days=1)
return [d.isoformat() for d in window if d >= first_day]
def delivery_window(sms_sent_at, work_days=None, now=None):
"""Даты, на которые предлагаем доставку.
Правило владельца: доставляем **только рабочими днями** и никогда в день
готовности — нужно время собрать маршрут. Значит, окно — два ближайших
рабочих дня после готовности:
• готов в понедельник → вт, ср
• готов в пятницу → пн, вт (в субботу и воскресенье не доставляем)
• звонок в среду по заказу, готовому в понедельник → ср, чт
"""
now = now or datetime.now(timezone.utc)
work_days = work_days or [1, 2, 3, 4, 5]
tz = timezone(timedelta(hours=3))
today = now.astimezone(tz).date()
ready = (sms_sent_at or now).astimezone(tz).date()
start = max(today, ready + timedelta(days=1))
return [d.isoformat() for d in work_day_window(start, work_days, 2)]
def is_first_half_available(now=None, half_end_minutes=None):
"""До границы половины дня (настройка склада, по умолчанию 12:00)
первую половину дня ещё можно предлагать клиенту."""
now = now or datetime.now(timezone.utc)
local = now.astimezone(timezone(timedelta(hours=3)))
limit = 12 * 60 if half_end_minutes is None else half_end_minutes
return local.hour * 60 + local.minute < limit
def resolve_call_type(group):
"""Тип звонка: pkp (самовывоз) или dlv (доставка).
Приоритет — явный call_type (ручной/тестовый запрос из очереди): его
выбирает логист. Если не задан — определяем по типу получения заказа.
От типа зависит озвучка (ru/ss_*_pkp vs ru/ss_*_dlv).
"""
ct = (group.get("call_type") or "").strip().lower()
if ct in ("dlv", "pkp"):
return ct
return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv"
def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
"""Формирует содержимое call-файла.
manual=True — звонок запущен руками из карточки заказа. Логист выбрал
конкретного клиента, поэтому звоним ему, даже если кампания в тестовом
режиме: тестовый режим страхует массовый обзвон, а не ручной запрос.
"""
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
phone = normalize_phone(phone) phone = normalize_phone(phone)
# Формат набора: 0 + номер без ведущей 7 # Формат набора: 0 + номер без ведущей 7
@ -148,12 +432,38 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
test_mode = bool(settings.get("test_mode", True)) test_mode = bool(settings.get("test_mode", True))
if is_test_call: if is_test_call:
test_mode = True test_mode = True
elif manual and group.get("id"):
test_mode = False
elif test_mode: elif test_mode:
test_phone = normalize_phone(settings.get("test_phone", "9067029953")) test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
scenario = group.get("scenario", "A") # выбор дня + времени # Сценарий: в день добавления группы → A (завтра/послезавтра),
call_type = "dlv" # со следующего дня → B (только завтра + половина дня).
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
call_type = resolve_call_type(group)
# Самовывоз: конкретные даты передаём в диалог явно — робот называет их
# голосом и возвращает выбранную дату, а не абстрактный «день 1/2».
pickup_days = []
if call_type == "pkp":
pickup_days = pickup_window(
group.get("sms_sent_at") or group.get("created_at"),
settings.get("pickup_work_days"),
cutoff_minutes=(settings.get("work_hours") or {}).get("pickup_same_day_cutoff"),
)
if not pickup_days:
# Окно самовывоза закрыто (заказ готов более двух дней назад).
return None
# Доставка: только рабочие дни графика и не в день готовности. Суббота —
# выходной, поэтому «готов в пятницу» даёт понедельник и вторник.
delivery_days = []
if call_type == "dlv":
delivery_days = delivery_window(
group.get("sms_sent_at") or group.get("created_at"),
settings.get("delivery_work_days"),
)
lines = [ lines = [
f"Channel: PJSIP/{dial}@mmedia", f"Channel: PJSIP/{dial}@mmedia",
@ -169,6 +479,12 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
f"Setvar: GROUP_ID={group.get('id') or ''}", f"Setvar: GROUP_ID={group.get('id') or ''}",
f"Setvar: ATTEMPT={attempt_no}", f"Setvar: ATTEMPT={attempt_no}",
f"Setvar: CUSTOMER_PHONE={phone}", f"Setvar: CUSTOMER_PHONE={phone}",
f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 else ''}",
f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 else ''}",
f"Setvar: PICKUP_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}",
f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}",
f"Setvar: DELIVERY_DAY1={delivery_days[0] if len(delivery_days) > 0 else ''}",
f"Setvar: DELIVERY_DAY2={delivery_days[1] if len(delivery_days) > 1 else ''}",
f"Setvar: API_URL={API_URL}", f"Setvar: API_URL={API_URL}",
f"Setvar: API_KEY={API_KEY}", f"Setvar: API_KEY={API_KEY}",
f"Setvar: TEST_MODE={1 if test_mode else 0}", f"Setvar: TEST_MODE={1 if test_mode else 0}",
@ -176,9 +492,13 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
] ]
return "\n".join(lines) return "\n".join(lines)
def push_call_file(group, settings, attempt_no, is_test_call=False): def push_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
"""Кладёт call-файл на АТС через SSH.""" """Кладёт call-файл на АТС через SSH."""
content = build_call_file(group, settings, attempt_no, is_test_call) content = build_call_file(group, settings, attempt_no, is_test_call, manual)
if content is None:
# Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем.
log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка")
return False
gid = str(group.get("id") or "test")[:8] gid = str(group.get("id") or "test")[:8]
filename = f"ss-{gid}-{int(time.time())}.call" filename = f"ss-{gid}-{int(time.time())}.call"
# Пишем файл на АТС через sshpass + cat # Пишем файл на АТС через sshpass + cat
@ -204,7 +524,8 @@ def get_queued_calls(conn):
"""Ручные запросы «Позвонить сейчас» из autocall_queue.""" """Ручные запросы «Позвонить сейчас» из autocall_queue."""
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute("""
SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call,
q.call_type
FROM autocall_queue q FROM autocall_queue q
WHERE q.processed_at IS NULL WHERE q.processed_at IS NULL
ORDER BY q.requested_at ASC ORDER BY q.requested_at ASC
@ -215,7 +536,9 @@ def get_queued_calls(conn):
ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"]) ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"])
if ids: if ids:
cur.execute(""" cur.execute("""
SELECT og.id, og.customer_phone, og.customer_phone_normalized SELECT og.id, og.customer_phone, og.customer_phone_normalized,
og.created_at, og.delivery_type, og.town,
og.notification_status, og.delivery_date, og.autocall_opt_out
FROM order_groups og WHERE og.id IN %s FROM order_groups og WHERE og.id IN %s
""", (ids,)) """, (ids,))
og = {r["id"]: r for r in cur.fetchall()} og = {r["id"]: r for r in cur.fetchall()}
@ -224,6 +547,12 @@ def get_queued_calls(conn):
if g: if g:
r["customer_phone_normalized"] = g.get("customer_phone_normalized") r["customer_phone_normalized"] = g.get("customer_phone_normalized")
r.setdefault("customer_phone", g.get("customer_phone")) r.setdefault("customer_phone", g.get("customer_phone"))
r.setdefault("created_at", g.get("created_at"))
r.setdefault("delivery_type", g.get("delivery_type"))
r.setdefault("town", g.get("town"))
r["notification_status"] = g.get("notification_status")
r["delivery_date"] = g.get("delivery_date")
r["autocall_opt_out"] = g.get("autocall_opt_out")
return rows return rows
@ -249,6 +578,33 @@ def process_queue(conn, settings):
if not phone: if not phone:
log.warning(f"Queue item {q['queue_id']} has no phone, skip") log.warning(f"Queue item {q['queue_id']} has no phone, skip")
continue continue
# Гео-исключение (как для SMS): город исключён целиком
city_excl = get_city_exclusion(conn, q.get("town"))
if city_excl:
log.info(f"Queue item {q['queue_id']} skipped by city rule ({q.get('town')}): {city_excl['reason']}")
mark_queue_processed(conn, q["queue_id"])
continue
# Исключение: на этот номер не звоним (например, МТС-диапазон)
excl = get_exclusion_match(conn, phone)
if excl:
log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
mark_queue_processed(conn, q["queue_id"])
continue
# Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) —
# роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено.
# Заявку НЕ закрываем (правило владельца) — просто не обрабатываем.
# ВАЖНО: 'manual_required' («нужен менеджер») сюда не относится — это как раз
# заказ, где клиент дату НЕ выбрал, звонок робота по нему допустим.
agreed = (str(q.get("notification_status") or "") == "confirmed"
or q.get("delivery_date") is not None
or q.get("autocall_opt_out") is True)
if agreed and not is_test:
log.info(
f"Queue item {q['queue_id']} (group {group_id}): заказ уже согласован "
f"(статус {q.get('notification_status')}, дата {q.get('delivery_date')}) — "
"звонок не нужен, заявка остаётся в очереди"
)
continue
if group_id not in attempt_map: if group_id not in attempt_map:
if group_id: if group_id:
with conn.cursor() as cur: with conn.cursor() as cur:
@ -263,9 +619,18 @@ def process_queue(conn, settings):
# Для теста не подставляем customer_phone_normalized (нет заказа) # Для теста не подставляем customer_phone_normalized (нет заказа)
if is_test: if is_test:
g["customer_phone_normalized"] = None g["customer_phone_normalized"] = None
if push_call_file(g, settings, attempt_no, is_test_call=is_test): if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
mark_queue_processed(conn, q["queue_id"]) mark_queue_processed(conn, q["queue_id"])
pushed += 1 pushed += 1
else:
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
# по правилу владельца заявка живёт, пока окно не откроется или
# пока её не обработают вручную. Интерфейс в этот момент блокирует
# кнопку автодозвона и объясняет причину (autocallAvailability.js).
log.info(
f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — "
"заявка остаётся в очереди до открытия окна"
)
attempt_map[group_id] = attempt_no + 1 attempt_map[group_id] = attempt_no + 1
return pushed return pushed
@ -277,10 +642,109 @@ def has_pending_items(conn):
return cur.fetchone() is not None return cur.fetchone() is not None
def backfill_no_answer(conn, settings):
"""Дописывает no_answer звонкам, которые не получили ответа сети.
Call-файл с истёкшим WaitTime не запускает AGI — в autocall_log ничего
не пишется, и «не дозвонились» не появляется никогда. Ищем обработанные
заявки старше 10 минут без строки в логе и дописываем no_answer.
Самозакрывающаяся: вставленная строка сама удовлетворяет проверке
существования, повторов не будет.
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT q.id, q.order_group_id, q.phone, q.is_test_call,
q.requested_at, q.processed_at, q.call_type
FROM autocall_queue q
WHERE q.processed_at IS NOT NULL
AND q.processed_at < NOW() - make_interval(mins => 10)
ORDER BY q.processed_at ASC
LIMIT 20
""")
rows = [dict(r) for r in cur.fetchall()]
backfilled = 0
for q in rows:
gid = q["order_group_id"]
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if gid:
cur.execute("""
SELECT 1 FROM autocall_log l
WHERE l.order_group_id = %s AND l.created_at >= %s LIMIT 1
""", (gid, q["requested_at"]))
else:
cur.execute("""
SELECT 1 FROM autocall_log l
WHERE l.order_group_id IS NULL
AND l.customer_phone = %s
AND l.created_at >= %s LIMIT 1
""", (normalize_phone(q["phone"] or ""), q["requested_at"]))
if cur.fetchone():
continue # строка есть (AGI отработал) — дописывать нечего
# Телефон из заказа (или нормализованный из заявки для pure-теста)
phone = normalize_phone(q["phone"] or "")
group_created = None
group_dtype = None
if gid:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT customer_phone_normalized, customer_phone, created_at,
delivery_type, town
FROM order_groups WHERE id = %s
""", (gid,))
grow = cur.fetchone() or {}
phone = (grow.get("customer_phone_normalized")
or normalize_phone(grow.get("customer_phone") or "")
or phone)
group_created = grow.get("created_at")
group_dtype = grow.get("delivery_type")
# По исключённым номерам звонка не было — строку не пишем
# (статус «исключение» и так показывается из настроек).
if get_exclusion_match(conn, phone):
continue
# Гео-исключение: город, по которому звонки не делаем вовсе
if get_city_exclusion(conn, q.get("town")):
continue
scenario = resolve_scenario(group_created) if gid else "A"
with conn.cursor() as cur:
if gid:
cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (gid,))
attempt_no = (cur.fetchone()[0] or 0) + 1
else:
attempt_no = 1
test_mode = bool(q["is_test_call"] or settings.get("test_mode", True))
call_type = resolve_call_type({"call_type": q.get("call_type"), "delivery_type": group_dtype})
with conn.cursor() as cur:
cur.execute("""
INSERT INTO autocall_log
(order_group_id, customer_phone, attempt_no, scenario,
call_started_at, outcome, was_test_mode, channel_state, call_type)
VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer', %s)
""", (gid, phone, attempt_no, scenario,
q["processed_at"] or q["requested_at"], test_mode, call_type))
conn.commit()
backfilled += 1
log.info(f"Backfilled no_answer for queue item {q['id']} (phone={phone})")
return backfilled
def main(): def main():
conn = get_db_conn() conn = get_db_conn()
try: try:
settings = load_settings(conn) settings = load_settings(conn)
# Рабочие дни и часы из «Графика работы» (админка): выходные и нерабочие
# дни робот не предлагает ни по самовывозу, ни по доставке.
settings["pickup_work_days"] = load_work_days(conn, "pickup_days")
settings["delivery_work_days"] = load_work_days(conn, "delivery_days")
settings["work_hours"] = load_work_hours(conn)
# Добор «не взял трубку»: заявки, по которым сеть так и не ответила
# (истёкший call-файл, AGI не запускался). Только работа с БД.
try:
nb = backfill_no_answer(conn, settings)
if nb:
log.info(f"Backfilled {nb} no_answer rows")
except Exception as e:
log.warning(f"backfill_no_answer failed: {e}")
# Fast-path: если кампания выключена и очередь пуста — выходим молча. # Fast-path: если кампания выключена и очередь пуста — выходим молча.
# Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе. # Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе.
@ -305,6 +769,17 @@ def main():
pushed = 0 pushed = 0
for group in groups: for group in groups:
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
# Гео-исключение (то же, что для SMS): город исключён целиком
city_excl = get_city_exclusion(conn, group.get("town"))
if city_excl:
log.info(f"Group {group['id']} skipped by city rule ({group.get('town')}): {city_excl['reason']}")
continue
# Исключение: на этот номер не звоним (например, МТС-диапазон)
excl = get_exclusion_match(conn, phone)
if excl:
log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
continue
attempt_no = (group.get("attempt_count") or 0) + 1 attempt_no = (group.get("attempt_count") or 0) + 1
if push_call_file(group, settings, attempt_no): if push_call_file(group, settings, attempt_no):
pushed += 1 pushed += 1

View File

@ -172,7 +172,7 @@ def send_telegram(message, chat_id):
# ─── State Machine ─────────────────────────────────────────────────────────── # ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_send(conn): def get_groups_to_send(conn, max_attempts):
"""Группы, которым ещё не отправили SMS. """Группы, которым ещё не отправили SMS.
Режимы (из sms_city_profiles): Режимы (из sms_city_profiles):
@ -183,7 +183,7 @@ def get_groups_to_send(conn):
cur.execute(""" cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status, og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.delivery_type, og.town, og.delivery_type, og.town, og.sms_attempts,
-- Профиль города (fallback на дефолтные значения если нет записи) -- Профиль города (fallback на дефолтные значения если нет записи)
COALESCE(scp.delivery_mode, 'link') as delivery_mode, COALESCE(scp.delivery_mode, 'link') as delivery_mode,
COALESCE(scp.delivery_text, '') as delivery_text, COALESCE(scp.delivery_text, '') as delivery_text,
@ -210,6 +210,16 @@ def get_groups_to_send(conn):
AND COALESCE(og.notification_status, '') = 'link_ready' AND COALESCE(og.notification_status, '') = 'link_ready'
-- Профиль sms_enabled (замена старого sms_allowed_for_city) -- Профиль sms_enabled (замена старого sms_allowed_for_city)
AND COALESCE(scp.sms_enabled, true) = true AND COALESCE(scp.sms_enabled, true) = true
-- Гео-исключения — общие для SMS и звонков: правило города
-- (campaign_type '*' — все каналы) блокирует и отправку SMS.
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'first_sms')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
-- Нет активной SMS в логе (sent/checking) за последние 2 часа -- Нет активной SMS в логе (sent/checking) за последние 2 часа
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl SELECT 1 FROM sms_campaign_log scl
@ -218,8 +228,15 @@ def get_groups_to_send(conn):
AND scl.status IN ('sent', 'checking') AND scl.status IN ('sent', 'checking')
AND scl.created_at > NOW() - INTERVAL '2 hours' AND scl.created_at > NOW() - INTERVAL '2 hours'
) )
-- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку,
-- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны)
AND (
SELECT count(*) FROM sms_campaign_log scl2
WHERE scl2.order_group_id = og.id
AND scl2.campaign_type = 'first_sms'
) < %s
ORDER BY og.created_at ASC ORDER BY og.created_at ASC
""") """, (max_attempts,))
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def get_sms_to_check(conn, max_duration_min): def get_sms_to_check(conn, max_duration_min):
@ -300,6 +317,22 @@ def update_order_group(conn, group_id, fields):
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values) cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
conn.commit() conn.commit()
def count_sms_attempts(conn, group_id, campaign_type="first_sms"):
"""Сколько раз эту SMS уже пытались отправить (строк в логе этой кампании).
Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
новую строку, поэтому в самой строке всегда было бы attempts=1, условие
«attempts < max_attempts» выполнялось бы вечно и кампания слала бы SMS
каждые 5 минут бесконечно (проверено на заказе ТП-010543: 457 отправок за
3,5 суток, ни одна не доставлена, в ручную работу заказ не ушёл).
"""
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) FROM sms_campaign_log
WHERE order_group_id = %s AND campaign_type = %s
""", (group_id, campaign_type))
return cur.fetchone()[0]
# ─── Основная логика ───────────────────────────────────────────────────────── # ─── Основная логика ─────────────────────────────────────────────────────────
def step_send_new(conn, settings): def step_send_new(conn, settings):
@ -309,7 +342,7 @@ def step_send_new(conn, settings):
max_attempts = settings.get("max_attempts", 2) max_attempts = settings.get("max_attempts", 2)
second_sms_delay = settings.get("second_sms_delay_hours", 3) second_sms_delay = settings.get("second_sms_delay_hours", 3)
groups = get_groups_to_send(conn) groups = get_groups_to_send(conn, max_attempts)
log.info(f"Step 1: {len(groups)} groups to send SMS") log.info(f"Step 1: {len(groups)} groups to send SMS")
sent_count = 0 sent_count = 0
@ -353,6 +386,8 @@ def step_send_new(conn, settings):
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})") log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
attempt_no = count_sms_attempts(conn, group_id) + 1
log.info(f"Group {group_id}: attempt {attempt_no}/{max_attempts}")
sms_id, raw, code = send_sms(send_phone, sms_text, api_id) sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id: if sms_id:
@ -364,7 +399,7 @@ def step_send_new(conn, settings):
sms_text=sms_text, sms_text=sms_text,
status="sent", status="sent",
sms_code=code, sms_code=code,
attempts=1, attempts=attempt_no,
) )
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
if sms_mode == "notification": if sms_mode == "notification":
@ -374,6 +409,7 @@ def step_send_new(conn, settings):
"sms_sent_at": "NOW()", "sms_sent_at": "NOW()",
"first_sms_sent_at": "NOW()", "first_sms_sent_at": "NOW()",
"status": "first_sms_sent", "status": "first_sms_sent",
"sms_attempts": attempt_no,
}) })
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)") log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
else: else:
@ -381,6 +417,7 @@ def step_send_new(conn, settings):
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "sms_sending", "notification_status": "sms_sending",
"sms_sent_at": "NOW()", "sms_sent_at": "NOW()",
"sms_attempts": attempt_no,
}) })
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending") log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
sent_count += 1 sent_count += 1
@ -394,7 +431,7 @@ def step_send_new(conn, settings):
sms_text=sms_text, sms_text=sms_text,
status="send_failed", status="send_failed",
sms_code=code, sms_code=code,
attempts=1, attempts=attempt_no,
error_message=error, error_message=error,
) )
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}") log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
@ -402,6 +439,7 @@ def step_send_new(conn, settings):
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "send_failed", "notification_status": "send_failed",
"last_sms_error": error[:200], "last_sms_error": error[:200],
"sms_attempts": attempt_no,
}) })
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat) send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
@ -425,7 +463,7 @@ def step_check_status(conn, settings):
group_id = str(item["order_group_id"]) group_id = str(item["order_group_id"])
name = item.get("customer_name") or item.get("group_key", "—") name = item.get("customer_name") or item.get("group_key", "—")
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) attempts = item.get("attempts") or 1
was_needs_check = item.get("needs_check", False) was_needs_check = item.get("needs_check", False)
code, raw, api_code = check_sms_status(sms_id, api_id) code, raw, api_code = check_sms_status(sms_id, api_id)
@ -471,9 +509,11 @@ def step_check_status(conn, settings):
}) })
else: else:
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "send_failed", "notification_status": "manual_required",
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})", "last_sms_error": f"SMS не доставлена после {max_attempts} попыток (код {code})",
}) })
log.error(f"Group {group_id}: attempts exhausted ({attempts}/{max_attempts}), "
f"notification_status→manual_required")
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat) send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
elif code in LIMIT_ERROR_CODES: elif code in LIMIT_ERROR_CODES:
@ -509,7 +549,7 @@ def step_handle_expired(conn, settings):
group_id = str(item["order_group_id"]) group_id = str(item["order_group_id"])
name = item.get("customer_name") or item.get("group_key", "—") name = item.get("customer_name") or item.get("group_key", "—")
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) attempts = item.get("attempts") or 1
delivery_link = item.get("delivery_link", "") delivery_link = item.get("delivery_link", "")
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
@ -534,6 +574,63 @@ def step_handle_expired(conn, settings):
tg_chat, tg_chat,
) )
def step_release_exhausted(conn, settings):
"""Шаг 4: группы, у которых попытки исчерпаны, а статус остался 'link_ready'.
Такое состояние возникает как следствие сбоя счётчика: группа выпадает из
выборки по лимиту попыток, но из очереди не уходит — висит в 'link_ready'
вечно, клиент не получает ссылку, в ручную работу заказ не переводится.
Шаг закрывает эту дыру: попытки кончились → «нужен менеджер».
"""
max_attempts = settings.get("max_attempts", 2)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
(SELECT count(*) FROM sms_campaign_log l
WHERE l.order_group_id = og.id
AND l.campaign_type = 'first_sms') AS attempts
FROM order_groups og
WHERE og.delivery_status = 'pending_confirmation'
AND COALESCE(og.notification_status, '') = 'link_ready'
AND (
SELECT count(*) FROM sms_campaign_log l
WHERE l.order_group_id = og.id
AND l.campaign_type = 'first_sms'
) >= %s
-- Активная отправка в работе — не мешаем ей
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log l2
WHERE l2.order_group_id = og.id
AND l2.campaign_type = 'first_sms'
AND l2.status IN ('sent', 'checking')
)
""", (max_attempts,))
groups = [dict(r) for r in cur.fetchall()]
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "—")
phone = group.get("customer_phone", "")
attempts = group.get("attempts") or max_attempts
log.warning(f"Group {group_id}: попыток {attempts}/{max_attempts}, статус остался link_ready "
f"→ manual_required (клиент SMS не получил)")
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"last_sms_error": f"SMS не доставлена после {attempts} попыток — нужен менеджер",
})
send_telegram(
f"🔧 <b>Нужен менеджер</b>\n{name} ({phone})\n"
f"SMS не доставлена после {attempts} попыток — клиенту не ушла ссылка",
tg_chat,
)
if groups:
log.info(f"Step 4: {len(groups)} groups released from link_ready → manual_required")
return len(groups)
# ─── Main ──────────────────────────────────────────────────────────────────── # ─── Main ────────────────────────────────────────────────────────────────────
def main(): def main():
@ -555,8 +652,9 @@ def main():
sent = step_send_new(conn, settings) sent = step_send_new(conn, settings)
delivered = step_check_status(conn, settings) delivered = step_check_status(conn, settings)
step_handle_expired(conn, settings) step_handle_expired(conn, settings)
released = step_release_exhausted(conn, settings)
log.info(f"Run summary: sent={sent}, delivered={delivered}") log.info(f"Run summary: sent={sent}, delivered={delivered}, released_to_manual={released}")
if sent > 0 or delivered > 0: if sent > 0 or delivered > 0:
send_telegram( send_telegram(

View File

@ -84,6 +84,22 @@ def get_db_conn():
user=DB_USER, password=DB_PASS, user=DB_USER, password=DB_PASS,
) )
def apply_schedule_work_days(conn, settings):
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
Если график прочитать не удалось, остаются прежние дни кампании.
"""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone() or {}
days = row.get("sms_days") or []
if days:
settings["work_days"] = ",".join(str(int(d)) for d in days)
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
return settings
def load_settings(conn): def load_settings(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'") cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
@ -99,7 +115,8 @@ def load_settings(conn):
"test_mode": True, "test_mode": True,
"test_phone": "79788382260", "test_phone": "79788382260",
} }
return dict(row) settings = dict(row)
return apply_schedule_work_days(conn, settings)
# ─── SMS API ───────────────────────────────────────────────────────────────── # ─── SMS API ─────────────────────────────────────────────────────────────────

View File

@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine)
Заменяет n8n workflow "Ручное управление" Заменяет n8n workflow "Ручное управление"
Логика: Логика:
Если после второй SMS (notification_status = 'second_sms_sent') Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч)
прошло auto_manual_after_hours и клиент не согласовал после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и
(delivery_status = 'pending_confirmation'): (delivery_status = 'pending_confirmation'):
→ notification_status = 'manual_required' → notification_status = 'manual_required'
→ delivery_status = 'manual_confirmation_required' → delivery_status = 'manual_confirmation_required'
→ next_notification_check_at = +3 мин
→ Telegram-уведомление → Telegram-уведомление
Ожидание считаем только по тем каналам, что реально включены в системе
(load_channels):
• второе приглашение включено → ждём, пока оно уйдёт;
• автодозвон включён и номеру звонить можно → ждём звонок, отсчёт
начнётся от него (позвонили позже — ожидание начинается заново);
• канал выключен / звонить нельзя (не звонить, гео, исключение) —
не ждём его и переводим по сроку ожидания.
Расписание: 9-21, Пн-Сб (настраивается из админки) Расписание: 9-21, Пн-Сб (настраивается из админки)
Скрипт НЕ отправляет SMS — только перевод в ручное управление. Скрипт НЕ отправляет SMS — только перевод в ручное управление.
""" """
@ -61,13 +68,30 @@ def get_db_conn():
user=DB_USER, password=DB_PASS, user=DB_USER, password=DB_PASS,
) )
def apply_schedule_work_days(conn, settings):
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
Если график прочитать не удалось, остаются прежние дни кампании.
"""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone() or {}
days = row.get("sms_days") or []
if days:
settings["work_days"] = ",".join(str(int(d)) for d in days)
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
return settings
def load_settings(conn): def load_settings(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID} return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
return dict(row) settings = dict(row)
return apply_schedule_work_days(conn, settings)
# ─── Telegram ──────────────────────────────────────────────────────────────── # ─── Telegram ────────────────────────────────────────────────────────────────
@ -94,52 +118,129 @@ def is_within_work_hours(settings):
# ─── State Machine ─────────────────────────────────────────────────────────── # ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_manual(conn): def get_groups_to_manual(conn, channels=None, manual_after_hours=3):
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление. """Группы, где клиент не выбрал дату/время и пора в ручную работу.
Сценарии: Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого
1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS приглашения: если клиенту позвонили позже, ожидание начинается заново.
2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS
3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа Каналы берём из настроек: ждём только те шаги, которые реально включены.
4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа • второе приглашение включено → ждём, пока оно уйдёт;
• автодозвон включён и этому номеру звонить можно → ждём звонка;
• канал выключен (или звонить нельзя: не звонить/гео/исключение) —
не ждём его и переводим в ручную работу по сроку ожидания.
Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие
заказы в системе ведёт только менеджер, автоматика их не трогает.
""" """
channels = channels or {}
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute(r"""
WITH contact AS (
SELECT og.id,
GREATEST(
COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'),
COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'),
COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'),
COALESCE(og.called_at, TIMESTAMP 'epoch'),
COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l
WHERE l.order_group_id = og.id
AND COALESCE(l.status, '') IN
('sent','checking','delivered','send_failed','error','limit_exceeded')),
TIMESTAMP 'epoch'),
COALESCE((SELECT max(a.created_at) FROM autocall_log a
WHERE a.order_group_id = og.id), TIMESTAMP 'epoch')
) AS last_contact_at,
(SELECT count(*) FROM autocall_log a
WHERE a.order_group_id = og.id) AS call_attempts
FROM order_groups og
),
callable AS (
SELECT og.id,
(og.autocall_opt_out = false
AND COALESCE(og.customer_phone, '') <> ''
AND NOT EXISTS (
SELECT 1 FROM autocall_city_rules cr
WHERE cr.city = og.town AND cr.calls_enabled = false)
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules cr2
WHERE cr2.city = og.town AND cr2.enabled = false
AND cr2.campaign_type IN ('*', 'autocall_slot')
AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE)
AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE))
AND NOT EXISTS (
SELECT 1 FROM autocall_exclusions e
WHERE e.active = true
AND ((e.rule_type = 'phone'
AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
OR (e.rule_type = 'prefix'
AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
LIKE e.value || '%%')))
) AS can_call
FROM order_groups og
)
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.notification_status, og.delivery_link, og.notification_status, og.delivery_link,
og.first_sms_sent_at, og.second_sms_sent_at, og.first_sms_sent_at, og.second_sms_sent_at,
og.sms_sent_at, og.next_notification_check_at, og.sms_sent_at, og.next_notification_check_at,
og.sms_attempts og.sms_attempts,
c.last_contact_at, c.call_attempts, cl.can_call
FROM order_groups og FROM order_groups og
JOIN contact c ON c.id = og.id
JOIN callable cl ON cl.id = og.id
WHERE og.delivery_status = 'pending_confirmation' WHERE og.delivery_status = 'pending_confirmation'
AND ( -- Клиент уже выбрал дату/время — в ручную не переводим
-- 1. second_sms_sent + срок истёк AND og.delivery_date IS NULL
(COALESCE(og.notification_status, '') = 'second_sms_sent' -- Контакта не было — отсчитывать не от чего
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())) AND c.last_contact_at > TIMESTAMP 'epoch'
-- 2. first_sms_sent + срок истёк (>3h после отправки) -- Срок ожидания истёк от последнего контакта
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL) AND NOW() - c.last_contact_at >= make_interval(hours => %s)
-- Иначе second_campaign не успеет отправить вторую SMS -- Второе приглашение ждём, только если канал включён
OR (COALESCE(og.notification_status, '') = 'first_sms_sent' AND (NOT %s OR og.second_sms_sent_at IS NOT NULL)
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) -- Звонок ждём, только если автодозвон включён и звонить можно
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours' AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call)
AND og.second_sms_sent_at IS NOT NULL) -- Гео-исключения — общие для SMS и ручного управления
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе AND NOT EXISTS (
OR (COALESCE(og.notification_status, '') = 'link_ready' SELECT 1 FROM sms_city_rules r
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) WHERE r.city = og.town
AND NOT EXISTS ( AND r.enabled = false
SELECT 1 FROM sms_campaign_log scl AND r.campaign_type IN ('*', 'manual')
WHERE scl.order_group_id = og.id AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND scl.status IN ('sent', 'checking') AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
AND scl.created_at > NOW() - INTERVAL '2 hours'
))
-- 4. sms_sending/checking + >3h без доставки (зависло)
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
) )
ORDER BY og.created_at ASC ORDER BY c.last_contact_at ASC
""") """, (
int(manual_after_hours or 3),
bool(channels.get("second_sms")),
bool(channels.get("autocall")),
))
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def load_channels(conn):
"""Какие каналы связи сейчас включены в системе.
`first_sms` — первое приглашение, `second_sms` — повторное SMS,
`autocall` — робот-автодозвон (настройки кампании 'autocall_slot').
От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги.
"""
channels = {"first_sms": True, "second_sms": False, "autocall": False}
try:
with conn.cursor() as cur:
cur.execute("""
SELECT campaign_type, enabled
FROM sms_campaign_settings
WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot')
""")
for campaign_type, enabled in cur.fetchall():
if campaign_type == "autocall_slot":
channels["autocall"] = bool(enabled)
else:
channels[campaign_type] = bool(enabled)
except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS
pass
return channels
def update_order_group(conn, group_id, fields): def update_order_group(conn, group_id, fields):
with conn.cursor() as cur: with conn.cursor() as cur:
set_parts = [] set_parts = []
@ -156,35 +257,55 @@ def update_order_group(conn, group_id, fields):
# ─── Основная логика ───────────────────────────────────────────────────────── # ─── Основная логика ─────────────────────────────────────────────────────────
def step_move_to_manual(conn, settings): def step_move_to_manual(conn, settings, channels=None):
"""Перевод групп в ручное управление""" """Перевод групп в ручное управление"""
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
delay_minutes = settings.get("auto_manual_after_hours", 3) wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3)
channels = channels or load_channels(conn)
groups = get_groups_to_manual(conn) groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours)
log.info(f"Step 1: {len(groups)} groups to move to manual") log.info(
f"Step 1: {len(groups)} groups to move to manual "
f"(ожидание {wait_hours} ч от последнего контакта; каналы: "
f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, "
f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, "
f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})"
)
moved = 0 moved = 0
dry_run = os.environ.get("DRY_RUN") == "1"
for group in groups: for group in groups:
group_id = str(group["id"]) group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "—") name = group.get("customer_name") or group.get("group_key", "—")
phone = group.get("customer_phone", "") phone = group.get("customer_phone", "")
delivery_link = group.get("delivery_link", "") delivery_link = group.get("delivery_link", "")
attempts = group.get("sms_attempts") or 1
log.info(f"Group {group_id}: moving to manual_required ({name})") if dry_run:
log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})")
moved += 1
continue
log.info(
f"Group {group_id}: moving to manual_required ({name}); "
f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}"
)
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
"notification_status": "manual_required", "notification_status": "manual_required",
"delivery_status": "manual_confirmation_required", "delivery_status": "manual_confirmation_required",
"sms_attempts": 2, # Попытки НЕ подменяем: показываем столько, сколько реально отправлено
"sms_attempts": attempts,
"last_sms_error": None, "last_sms_error": None,
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'", # Дальше ждать нечего — проверки по этому заказу закончились
"next_notification_check_at": None,
"status": "manual_required", "status": "manual_required",
}) })
send_telegram( send_telegram(
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n" f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
f"Клиент не согласовал доставку после двух SMS\n" f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта "
f"({'звонки' if channels.get('autocall') else 'SMS'})\n"
f"Ссылка: {delivery_link}", f"Ссылка: {delivery_link}",
tg_chat, tg_chat,
) )
@ -246,8 +367,9 @@ def main():
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'") cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
conn.commit() conn.commit()
log.info("RESTART: manual campaign — will process all eligible groups now") log.info("RESTART: manual campaign — will process all eligible groups now")
else:
moved = step_move_to_manual(conn, settings) channels = load_channels(conn)
moved = step_move_to_manual(conn, settings, channels=channels)
log.info(f"Run summary: moved_to_manual={moved}") log.info(f"Run summary: moved_to_manual={moved}")

View File

@ -71,6 +71,22 @@ def get_db_conn():
user=DB_USER, password=DB_PASS, user=DB_USER, password=DB_PASS,
) )
def apply_schedule_work_days(conn, settings):
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
Если график прочитать не удалось, остаются прежние дни кампании.
"""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone() or {}
days = row.get("sms_days") or []
if days:
settings["work_days"] = ",".join(str(int(d)) for d in days)
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
return settings
def load_settings(conn): def load_settings(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
@ -82,7 +98,8 @@ def load_settings(conn):
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID, "telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
"work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5", "work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
} }
return dict(row) settings = dict(row)
return apply_schedule_work_days(conn, settings)
# ─── SMS API ───────────────────────────────────────────────────────────────── # ─── SMS API ─────────────────────────────────────────────────────────────────
@ -183,6 +200,15 @@ def get_groups_to_send(conn):
LEFT JOIN sms_city_profiles scp ON scp.city = og.town LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'paid_storage' WHERE og.delivery_status = 'paid_storage'
AND og.paid_storage_at IS NOT NULL AND og.paid_storage_at IS NOT NULL
-- Гео-исключения — общие для SMS и звонков
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'paid_storage')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent') AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
AND og.delivery_link IS NOT NULL AND og.delivery_link IS NOT NULL
AND og.delivery_link != '' AND og.delivery_link != ''

View File

@ -83,6 +83,22 @@ def get_db_conn():
user=DB_USER, password=DB_PASS, user=DB_USER, password=DB_PASS,
) )
def apply_schedule_work_days(conn, settings):
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
Если график прочитать не удалось, остаются прежние дни кампании.
"""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone() or {}
days = row.get("sms_days") or []
if days:
settings["work_days"] = ",".join(str(int(d)) for d in days)
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
return settings
def load_settings(conn): def load_settings(conn):
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
@ -104,7 +120,8 @@ def load_settings(conn):
"second_sms_delay_hours": 3, "second_sms_delay_hours": 3,
"auto_manual_after_hours": 3, "auto_manual_after_hours": 3,
} }
return dict(row) settings = dict(row)
return apply_schedule_work_days(conn, settings)
# ─── SMS API ───────────────────────────────────────────────────────────────── # ─── SMS API ─────────────────────────────────────────────────────────────────
@ -190,7 +207,7 @@ def send_telegram(message, chat_id):
# ─── State Machine ─────────────────────────────────────────────────────────── # ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_send(conn): def get_groups_to_send(conn, max_attempts):
"""Группы, где первая SMS доставлена, но клиент не согласовал, """Группы, где первая SMS доставлена, но клиент не согласовал,
и пришло время для второй SMS (next_notification_check_at <= NOW()). и пришло время для второй SMS (next_notification_check_at <= NOW()).
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена. second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
@ -209,6 +226,15 @@ def get_groups_to_send(conn):
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется) -- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
AND COALESCE(scp.second_sms_enabled, true) = true AND COALESCE(scp.second_sms_enabled, true) = true
-- Гео-исключения — общие для SMS и звонков
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
AND r.enabled = false
AND r.campaign_type IN ('*', 'second_sms')
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
)
-- Нет активной второй SMS в логе -- Нет активной второй SMS в логе
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl SELECT 1 FROM sms_campaign_log scl
@ -217,8 +243,15 @@ def get_groups_to_send(conn):
AND scl.status IN ('sent', 'checking') AND scl.status IN ('sent', 'checking')
AND scl.created_at > NOW() - INTERVAL '2 hours' AND scl.created_at > NOW() - INTERVAL '2 hours'
) )
-- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку,
-- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны)
AND (
SELECT count(*) FROM sms_campaign_log scl2
WHERE scl2.order_group_id = og.id
AND scl2.campaign_type = 'second_sms'
) < %s
ORDER BY og.created_at ASC ORDER BY og.created_at ASC
""") """, (max_attempts,))
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def get_sms_to_check(conn, max_duration_min): def get_sms_to_check(conn, max_duration_min):
@ -301,6 +334,21 @@ def update_order_group(conn, group_id, fields):
# ─── Проверка рабочего времени ──────────────────────────────────────────────── # ─── Проверка рабочего времени ────────────────────────────────────────────────
def count_sms_attempts(conn, group_id, campaign_type=CAMPAIGN_TYPE):
"""Сколько раз эту SMS уже пытались отправить (строк в логе кампании).
Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
новую строку, поэтому в строке всегда было бы attempts=1, условие
«attempts < max_attempts» выполнялось бы вечно и вторая SMS уходила бы
каждые 5 минут бесконечно.
"""
with conn.cursor() as cur:
cur.execute("""
SELECT COUNT(*) FROM sms_campaign_log
WHERE order_group_id = %s AND campaign_type = %s
""", (group_id, campaign_type))
return cur.fetchone()[0]
def is_within_work_hours(settings): def is_within_work_hours(settings):
"""Проверка: сейчас рабочие часы. """Проверка: сейчас рабочие часы.
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
@ -328,8 +376,9 @@ def step_send_new(conn, settings):
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования""" """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
api_id = settings.get("sms_api_id", SMS_API_ID) api_id = settings.get("sms_api_id", SMS_API_ID)
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
max_attempts = settings.get("max_attempts", 2)
groups = get_groups_to_send(conn) groups = get_groups_to_send(conn, max_attempts)
log.info(f"Step 1: {len(groups)} groups to send second SMS") log.info(f"Step 1: {len(groups)} groups to send second SMS")
sent_count = 0 sent_count = 0
@ -361,6 +410,8 @@ def step_send_new(conn, settings):
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})") log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
attempt_no = count_sms_attempts(conn, group_id) + 1
log.info(f"Group {group_id}: second SMS attempt {attempt_no}/{max_attempts}")
sms_id, raw, code = send_sms(send_phone, sms_text, api_id) sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
if sms_id: if sms_id:
@ -372,7 +423,7 @@ def step_send_new(conn, settings):
sms_text=sms_text, sms_text=sms_text,
status="sent", status="sent",
sms_code=code, sms_code=code,
attempts=1, attempts=attempt_no,
) )
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending # ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
update_order_group(conn, group_id, { update_order_group(conn, group_id, {
@ -390,7 +441,7 @@ def step_send_new(conn, settings):
sms_text=sms_text, sms_text=sms_text,
status="send_failed", status="send_failed",
sms_code=code, sms_code=code,
attempts=1, attempts=attempt_no,
error_message=error, error_message=error,
) )
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}") log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
@ -419,7 +470,7 @@ def step_check_status(conn, settings):
group_id = str(item["order_group_id"]) group_id = str(item["order_group_id"])
name = item.get("customer_name") or item.get("group_key", "—") name = item.get("customer_name") or item.get("group_key", "—")
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) attempts = item.get("attempts") or 1
code, raw, api_code = check_sms_status(sms_id, api_id) code, raw, api_code = check_sms_status(sms_id, api_id)
@ -501,7 +552,7 @@ def step_handle_expired(conn, settings):
group_id = str(item["order_group_id"]) group_id = str(item["order_group_id"])
name = item.get("customer_name") or item.get("group_key", "—") name = item.get("customer_name") or item.get("group_key", "—")
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) attempts = item.get("attempts") or 1
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
update_sms_log(conn, log_id, status="expired", update_sms_log(conn, log_id, status="expired",

View File

@ -1,26 +1,70 @@
/** /**
* @file CallStatusBadge.jsx * @file CallStatusBadge.jsx
* @description Бейдж коммуникационного статуса для строк реестра групп. * @description Бейдж автодозвона в строке реестра доставок.
* Показывает проблему автодозвона снаружи, не открывая карточку. *
* Приоритет сверху вниз — один бейдж наружу. * Раньше наружу выходили две безликие плашки: «Автодозвон ✅» и «Автодозвон ❌» —
* по ним нельзя было понять, не дозвонились, не выбрал клиент день/время или заказ ушёл
* менеджеру. Теперь у каждого состояния своя подпись, взятая из
* AUTOCALL_STATUS_META (тот же список, что в фильтрах, — не разъезжается).
*
* «Не звонили» показываем только по заказам, которые ещё ждут решения
* (pending_confirmation / manual_confirmation_required): в уже доставленных
* заказах такая плашка — просто шум.
*/ */
import React from "react"; import React from "react";
// Приоритет: отказ > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота import { AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
export const resolveCallBadge = (g) => {
if (!g) return null;
// 1. Отказ от звонков /** Статусы, при которых заказ ещё ждёт решения клиента. */
if (g.autocall_opt_out) { const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation_required"];
return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" };
}
// 2. Слот от робота (информационный, самый низкий приоритет — показываем если нет проблем) const STYLES = {
if (g.deliveryDateSource === "autocall") { confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]",
return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" }; no_answer: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
} no_input_day: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
no_input_half: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
no_input_both: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
manual: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
not_called: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
};
return null; const TITLES = {
confirmed: "Клиент согласовал день и половину дня по звонку робота",
no_answer: "Робот звонил, трубку не взяли",
no_input_day: "Трубку взяли, но день клиент не выбрал",
no_input_half: "День клиент выбрал, а время — нет",
no_input_both: "В разных звонках клиент не выбрал ни день, ни время",
manual: "Нужен менеджер: неверный номер или ошибка звонка",
opt_out: "Звонки по этому клиенту запрещены вручную",
excluded: "Исключение: по этому номеру или городу не звоним",
not_called: "Автодозвона по заказу ещё не было",
};
/** Ждёт ли заказ решения (тогда «не звонили» стоит показать). */
export const isAwaitingDecision = (group) => {
if (!group) return false;
const status = group.deliveryStatus || group.delivery_status || "";
return AWAITING_DELIVERY_STATUSES.includes(status);
};
/** Что показать в строке: null — показывать нечего. */
export const resolveCallBadge = (group) => {
if (!group) return null;
const status = group.autocallStatus || group.autocall_status || "not_called";
// Запрет и исключение важны всегда, независимо от стадии заказа.
if (status === "not_called" && !isAwaitingDecision(group)) return null;
const meta = AUTOCALL_STATUS_META[status];
if (!meta) return null;
return {
status,
label: `${meta.icon} ${meta.short}`,
cls: STYLES[status] || STYLES.not_called,
title: status === "excluded" && group.autocall_exclusion_reason
? `Исключение автодозвона: ${group.autocall_exclusion_reason}`
: TITLES[status] || meta.label,
};
}; };
export const CallStatusBadge = ({ group }) => { export const CallStatusBadge = ({ group }) => {

View File

@ -0,0 +1,97 @@
/**
* @file CallStatusBadge.test.jsx
* @description Бейдж автодозвона в реестре доставок: по плашке должно быть понятно,
* что с заказом — не звонили, согласовано, не дозвонились, не выбрал день/время,
* нужен менеджер, запрет или исключение. Без «Автодозвон ❌» на всё подряд.
*/
import { describe, it, expect } from "vitest";
import { CallStatusBadge, isAwaitingDecision, resolveCallBadge } from "./CallStatusBadge";
import { AUTOCALL_STATUS_META, AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
const group = (over = {}) => ({ id: "g1", deliveryStatus: "pending_confirmation", ...over });
/** Текст из разметки без служебных пробелов — как в остальных тестах проекта. */
const plain = (text) => String(text).replace(/[\u00a0\u202f]/g, " ");
describe("resolveCallBadge — что видно в строке", () => {
it("не звонили и заказ ещё ждёт решения — плашка есть", () => {
const badge = resolveCallBadge(group({ autocallStatus: "not_called" }));
expect(badge.label).toContain("звонка не было");
expect(badge.title).toContain("Автодозвона по заказу ещё не было");
});
it("не звонили, но заказ уже доставлен — плашки нет (не засоряем таблицу)", () => {
expect(resolveCallBadge(group({ autocallStatus: "not_called", deliveryStatus: "delivered" }))).toBeNull();
});
it("у каждого исхода своя подпись, а не одна общая «Автодозвон ❌»", () => {
const labels = ["confirmed", "no_answer", "no_input_day", "no_input_half", "no_input_both", "manual"].map(
(status) => resolveCallBadge(group({ autocallStatus: status })).label,
);
expect(new Set(labels).size).toBe(labels.length);
expect(labels[0]).toContain("согласовано");
expect(labels[1]).toContain("не дозвонились");
expect(labels[2]).toContain("не выбрал день");
expect(labels[3]).toContain("не выбрал время");
expect(labels[4]).toContain("не выбрал день и время");
expect(labels[5]).toContain("нужен менеджер");
});
it("«не выбрал день» и «не выбрал время» — разные подписи, а не одна общая", () => {
const day = resolveCallBadge(group({ autocallStatus: "no_input_day" }));
const half = resolveCallBadge(group({ autocallStatus: "no_input_half" }));
expect(day.label).not.toBe(half.label);
expect(day.title).toContain("день клиент не выбрал");
expect(half.title).toContain("День клиент выбрал, а время — нет");
});
it("запрет звонков и исключение видны даже в завершённом заказе", () => {
const stop = resolveCallBadge(group({ autocallStatus: "opt_out", deliveryStatus: "delivered" }));
expect(stop.label).toContain("не звонить");
const excluded = resolveCallBadge(group({
autocallStatus: "excluded",
deliveryStatus: "delivered",
autocall_exclusion_reason: "МТС-диапазон",
}));
expect(excluded.title).toContain("МТС-диапазон");
});
it("неизвестный статус не рисуем", () => {
expect(resolveCallBadge(group({ autocallStatus: "какой-то-старый" }))).toBeNull();
});
it("в разметке стоит именно подпись статуса", () => {
const html = CallStatusBadge({ group: group({ autocallStatus: "no_answer" }) });
expect(plain(html.props.className)).toContain("rounded-full");
expect(plain(html.props.children)).toContain("не дозвонились");
});
});
describe("isAwaitingDecision", () => {
it("ждёт решения только ожидающий подтверждения заказ", () => {
expect(isAwaitingDecision({ deliveryStatus: "pending_confirmation" })).toBe(true);
expect(isAwaitingDecision({ deliveryStatus: "manual_confirmation_required" })).toBe(true);
expect(isAwaitingDecision({ deliveryStatus: "delivered" })).toBe(false);
expect(isAwaitingDecision({ delivery_status: "agreed" })).toBe(false);
expect(isAwaitingDecision(null)).toBe(false);
});
});
describe("единый список статусов", () => {
it("фильтр и подписи не расходятся: у каждого пункта фильтра есть подпись", () => {
for (const option of AUTOCALL_STATUS_OPTIONS) {
expect(AUTOCALL_STATUS_META[option.value]).toBeTruthy();
}
expect(AUTOCALL_STATUS_OPTIONS).toHaveLength(Object.keys(AUTOCALL_STATUS_META).length);
});
it("в списках нет формулировок из удалённых сценариев", () => {
const all = [
...AUTOCALL_STATUS_OPTIONS.map((o) => o.label),
...Object.values(AUTOCALL_STATUS_META).map((m) => `${m.label} ${m.short}`),
].join(" ").toLowerCase();
expect(all).not.toContain("отказ от звонков");
expect(all).not.toContain("требует ручного управления");
});
});

View File

@ -0,0 +1,180 @@
import React, { useState, useRef, useEffect } from "react";
const MONTH_NAMES = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
];
const WEEKDAY_SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
function getDaysInMonth(year, month) { return new Date(year, month + 1, 0).getDate(); }
function getFirstDayOfWeek(year, month) { const d = new Date(year, month, 1).getDay(); return d === 0 ? 6 : d - 1; }
function formatDateISO(date) {
if (!date) return "";
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function formatDateDisplay(date) {
if (!date) return "";
return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`;
}
function parseDateFromISO(str) {
if (!str) return null;
const [y, m, d] = str.split("-").map(Number);
if (!y || !m || !d) return null;
return new Date(y, m - 1, d);
}
/**
* DateRangePicker — одно поле «Период» без лейбла.
* При клике открывает календарь: первый клик = «с», второй = «по».
* Показывает «С даты — По дату» или «Период» если пусто.
*/
export const DateRangePicker = ({ valueFrom = "", valueTo = "", onChange, className = "" }) => {
const [isOpen, setIsOpen] = useState(false);
const [viewYear, setViewYear] = useState(() => {
const d = parseDateFromISO(valueFrom) || new Date();
return d.getFullYear();
});
const [viewMonth, setViewMonth] = useState(() => {
const d = parseDateFromISO(valueFrom) || new Date();
return d.getMonth();
});
const [selecting, setSelecting] = useState("from"); // "from" | "to"
const containerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const handle = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) setIsOpen(false);
};
document.addEventListener("pointerdown", handle);
return () => document.removeEventListener("pointerdown", handle);
}, [isOpen]);
useEffect(() => {
const handle = (e) => { if (e.key === "Escape" && isOpen) setIsOpen(false); };
document.addEventListener("keydown", handle);
return () => document.removeEventListener("keydown", handle);
}, [isOpen]);
const fromDate = parseDateFromISO(valueFrom);
const toDate = parseDateFromISO(valueTo);
const handleDayClick = (day) => {
const d = new Date(viewYear, viewMonth, day);
if (selecting === "from") {
onChange(formatDateISO(d), valueTo);
setSelecting("to");
} else {
// Если выбрали дату раньше «с» — поменяем местами
const from = parseDateFromISO(valueFrom);
if (from && d < from) {
onChange(formatDateISO(d), valueFrom);
} else {
onChange(valueFrom, formatDateISO(d));
}
setIsOpen(false);
setSelecting("from");
}
};
const handleClear = (e) => {
e.stopPropagation();
onChange("", "");
setSelecting("from");
};
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); }
else setViewMonth((m) => m - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
else setViewMonth((m) => m + 1);
};
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfWeek(viewYear, viewMonth);
const today = new Date();
const isToday = (day) => day === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
const isInRange = (day) => {
const d = new Date(viewYear, viewMonth, day);
if (fromDate && toDate) return d >= fromDate && d <= toDate;
return false;
};
const isSelected = (day) => {
const d = new Date(viewYear, viewMonth, day);
return (fromDate && d.getTime() === fromDate.getTime()) || (toDate && d.getTime() === toDate.getTime());
};
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const displayText = fromDate
? `${formatDateDisplay(fromDate)} — ${toDate ? formatDateDisplay(toDate) : "…"}`
: "Период";
return (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
className={[
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
isOpen
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
].join(" ")}
>
<span className={fromDate ? "" : "text-[var(--color-text-muted)]"}>{displayText}</span>
<span className="ml-2 flex items-center gap-1">
{fromDate && (
<span role="button" tabIndex={-1} onClick={handleClear} className="text-[var(--color-text-muted)] hover:text-[var(--color-danger)] text-xs">✕</span>
)}
<span className="text-[var(--color-text-muted)]">📅</span>
</span>
</button>
{isOpen && (
<div className="absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft" style={{ minWidth: "280px" }}>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<button type="button" onClick={prevMonth} className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm">◀</button>
<span className="text-sm font-semibold text-[var(--color-text)]">{MONTH_NAMES[viewMonth]} {viewYear}</span>
<button type="button" onClick={nextMonth} className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm">▶</button>
</div>
<div className="px-3 pt-1.5 text-[11px] text-[var(--color-text-muted)]">
{selecting === "from" ? "Выберите начало периода" : "Выберите конец периода"}
</div>
<div className="grid grid-cols-7 px-2 pt-1">
{WEEKDAY_SHORT.map((wd) => (
<div key={wd} className="flex h-8 items-center justify-center text-xs font-semibold text-[var(--color-text-muted)]">{wd}</div>
))}
</div>
<div className="grid grid-cols-7 gap-0 px-2 pb-1">
{cells.map((day, i) => (
<button
key={i}
type="button"
disabled={!day}
onClick={day ? () => handleDayClick(day) : undefined}
className={[
"flex h-9 w-full items-center justify-center rounded-xl text-sm transition",
!day ? "" :
isSelected(day) ? "bg-[var(--color-accent)] text-white font-bold shadow-sm" :
isInRange(day) ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
isToday(day) ? "border border-[var(--color-accent)] text-[var(--color-accent)] font-semibold" :
"text-[var(--color-text)] hover:bg-[var(--color-accent-soft)]",
].join(" ")}
>
{day || ""}
</button>
))}
</div>
<div className="border-t border-[var(--color-border)] px-2 py-2">
<button type="button" onClick={() => { const now = new Date(); setViewYear(now.getFullYear()); setViewMonth(now.getMonth()); }} className="w-full rounded-xl py-2 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition">Сегодня</button>
</div>
</div>
)}
</div>
);
};

View File

@ -23,8 +23,41 @@ const DEFAULT_SCHEDULE = {
deliveryDays: [1, 2, 3, 4, 5], deliveryDays: [1, 2, 3, 4, 5],
pickupDays: [1, 2, 3, 4, 5], pickupDays: [1, 2, 3, 4, 5],
smsDays: [1, 2, 3, 4, 5], smsDays: [1, 2, 3, 4, 5],
// Рабочие часы: склад (самовывоз) и доставка.
pickupOpen: "09:00",
pickupClose: "17:30",
pickupHalfEnd: "13:00",
pickupSameDayCutoff: "15:30",
deliveryOpen: "09:00",
deliveryClose: "21:00",
deliveryHalfEnd: "15:00",
}; };
const TIME_FIELDS = [
"pickupOpen",
"pickupClose",
"pickupHalfEnd",
"pickupSameDayCutoff",
"deliveryOpen",
"deliveryClose",
"deliveryHalfEnd",
];
const TimeField = ({ label, hint, value, onChange }) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{label}
</span>
<input
type="time"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="rounded-[14px] border-2 border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-2.5 text-sm font-semibold text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
/>
{hint ? <span className="text-xs text-[var(--color-text-muted)]">{hint}</span> : null}
</label>
);
const DayPills = ({ selectedDays, onToggle, accentColor }) => { const DayPills = ({ selectedDays, onToggle, accentColor }) => {
const toggle = (num) => { const toggle = (num) => {
if (selectedDays.includes(num)) { if (selectedDays.includes(num)) {
@ -79,6 +112,13 @@ export const BusinessSchedulePanel = () => {
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays, deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays, pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays, smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
pickupOpen: data.pickupOpen || DEFAULT_SCHEDULE.pickupOpen,
pickupClose: data.pickupClose || DEFAULT_SCHEDULE.pickupClose,
pickupHalfEnd: data.pickupHalfEnd || DEFAULT_SCHEDULE.pickupHalfEnd,
pickupSameDayCutoff: data.pickupSameDayCutoff || DEFAULT_SCHEDULE.pickupSameDayCutoff,
deliveryOpen: data.deliveryOpen || DEFAULT_SCHEDULE.deliveryOpen,
deliveryClose: data.deliveryClose || DEFAULT_SCHEDULE.deliveryClose,
deliveryHalfEnd: data.deliveryHalfEnd || DEFAULT_SCHEDULE.deliveryHalfEnd,
}; };
setSchedule(loaded); setSchedule(loaded);
setOriginal(loaded); setOriginal(loaded);
@ -97,7 +137,8 @@ export const BusinessSchedulePanel = () => {
const hasChanges = const hasChanges =
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) || JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) || JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays); JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays) ||
TIME_FIELDS.some((field) => schedule[field] !== original[field]);
const handleSave = async () => { const handleSave = async () => {
if (!hasChanges) return; if (!hasChanges) return;
@ -115,6 +156,13 @@ export const BusinessSchedulePanel = () => {
p_delivery_days: schedule.deliveryDays, p_delivery_days: schedule.deliveryDays,
p_pickup_days: schedule.pickupDays, p_pickup_days: schedule.pickupDays,
p_sms_days: schedule.smsDays, p_sms_days: schedule.smsDays,
p_pickup_open: schedule.pickupOpen,
p_pickup_close: schedule.pickupClose,
p_pickup_half_end: schedule.pickupHalfEnd,
p_pickup_same_day_cutoff: schedule.pickupSameDayCutoff,
p_delivery_open: schedule.deliveryOpen,
p_delivery_close: schedule.deliveryClose,
p_delivery_half_end: schedule.deliveryHalfEnd,
}); });
if (rpcError) throw rpcError; if (rpcError) throw rpcError;
if (data?.ok) { if (data?.ok) {
@ -153,6 +201,8 @@ export const BusinessSchedulePanel = () => {
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1> <h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений. Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
Дни самовывоза — единый источник правды: их же использует страница клиента по ссылке
и робот-автодозвон, в другие дни самовывоз не предлагается.
</p> </p>
</div> </div>
{savedAt && !hasChanges && ( {savedAt && !hasChanges && (
@ -169,7 +219,8 @@ export const BusinessSchedulePanel = () => {
Доставка Доставка
</h2> </h2>
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается. Дни, в которые клиент может выбрать доставку по ссылке. В остальные дни доставка
не предлагается.
</p> </p>
</div> </div>
</div> </div>
@ -180,6 +231,26 @@ export const BusinessSchedulePanel = () => {
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.deliveryDays) || "—"} Выбрано: {formatDays(schedule.deliveryDays) || "—"}
</p> </p>
<div className="grid gap-4 sm:grid-cols-3">
<TimeField
label="Начало доставки"
hint="с этого времени возим заказы"
value={schedule.deliveryOpen}
onChange={(v) => setSchedule((s) => ({ ...s, deliveryOpen: v }))}
/>
<TimeField
label="Окончание доставки"
hint="позже не доставляем"
value={schedule.deliveryClose}
onChange={(v) => setSchedule((s) => ({ ...s, deliveryClose: v }))}
/>
<TimeField
label="Первая половина — до"
hint="граница между половинами дня"
value={schedule.deliveryHalfEnd}
onChange={(v) => setSchedule((s) => ({ ...s, deliveryHalfEnd: v }))}
/>
</div>
</Panel> </Panel>
{/* Pickup days */} {/* Pickup days */}
@ -191,7 +262,8 @@ export const BusinessSchedulePanel = () => {
Самовывоз Самовывоз
</h2> </h2>
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается. Дни, в которые склад выдаёт самовывоз. В остальные дни самовывоз не предлагается —
ни в ссылке клиента, ни в звонке робота.
</p> </p>
</div> </div>
</div> </div>
@ -202,6 +274,32 @@ export const BusinessSchedulePanel = () => {
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
Выбрано: {formatDays(schedule.pickupDays) || "—"} Выбрано: {formatDays(schedule.pickupDays) || "—"}
</p> </p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<TimeField
label="Открытие склада"
hint="с этого времени выдаём заказы"
value={schedule.pickupOpen}
onChange={(v) => setSchedule((s) => ({ ...s, pickupOpen: v }))}
/>
<TimeField
label="Закрытие склада"
hint="позже заказы не выдаём"
value={schedule.pickupClose}
onChange={(v) => setSchedule((s) => ({ ...s, pickupClose: v }))}
/>
<TimeField
label="Первая половина — до"
hint="граница между половинами дня"
value={schedule.pickupHalfEnd}
onChange={(v) => setSchedule((s) => ({ ...s, pickupHalfEnd: v }))}
/>
<TimeField
label="Сегодня предлагаем до"
hint="позже робот и ссылка предложат только следующие дни — склад не успеет собрать заказ"
value={schedule.pickupSameDayCutoff}
onChange={(v) => setSchedule((s) => ({ ...s, pickupSameDayCutoff: v }))}
/>
</div>
</Panel> </Panel>
{/* SMS days */} {/* SMS days */}
@ -213,7 +311,7 @@ export const BusinessSchedulePanel = () => {
SMS-приглашения SMS-приглашения
</h2> </h2>
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно). Дни, в которые отправляются SMS-приглашения клиентам.
</p> </p>
</div> </div>
</div> </div>

View File

@ -10,6 +10,7 @@ import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
import { SmsCampaignStats } from "./SmsCampaignStats"; import { SmsCampaignStats } from "./SmsCampaignStats";
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel"; import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
import { resolveSmsBalance } from "../../services/smsBalance";
// ── Campaigns ─────────────────────────────────────────────────────────────── // ── Campaigns ───────────────────────────────────────────────────────────────
const CAMPAIGNS = [ const CAMPAIGNS = [
@ -114,6 +115,7 @@ export const SmsCampaignPanel = () => {
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
const [allSettings, setAllSettings] = useState({}); const [allSettings, setAllSettings] = useState({});
const [campaignCounts, setCampaignCounts] = useState({}); const [campaignCounts, setCampaignCounts] = useState({});
const [smsBalance, setSmsBalance] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
@ -145,6 +147,9 @@ export const SmsCampaignPanel = () => {
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; }); (allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
setAllSettings(settingsMap); setAllSettings(settingsMap);
// Баланс — тот же живой источник, что на главной странице
resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal));
// Load counts per campaign (for cards) // Load counts per campaign (for cards)
const { data: countsData, error: countsErr } = await supabase const { data: countsData, error: countsErr } = await supabase
.from("sms_campaign_log") .from("sms_campaign_log")
@ -317,17 +322,21 @@ export const SmsCampaignPanel = () => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */} {/* Balance banner — тот же живой баланс, что на главной (один источник) */}
{/* Balance banner */} {smsBalance?.balance != null && (
{allSettings.first_sms?.last_balance != null && (
<Panel className="p-3"> <Panel className="p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl">💰</span> <span className="text-2xl">💰</span>
<div> <div>
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div> <div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
<div className="text-lg font-bold text-[var(--color-text)]"> <div className="text-lg font-bold text-[var(--color-text)]">
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽ {smsBalance.balance.toLocaleString("ru-RU")} ₽
</div> </div>
{!smsBalance.live && (
<div className="text-[10px] text-[var(--color-text-muted)]">
показан баланс на момент последней рассылки — живой запрос не прошёл
</div>
)}
</div> </div>
</div> </div>
</Panel> </Panel>
@ -511,7 +520,7 @@ export const SmsCampaignPanel = () => {
)} )}
</> </>
)} )}
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} /> <SettingField label="В ручную работу через (часов после последнего контакта)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} hint="Отсчёт от последнего контакта: SMS или звонок робота. Если автодозвон включён — ждём звонок, если включено второе SMS — ждём его." />
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} /> <SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
</div> </div>
@ -804,7 +813,7 @@ export const SmsCampaignPanel = () => {
}; };
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const SettingField = ({ label, value, onChange }) => ( const SettingField = ({ label, value, onChange, hint }) => (
<label className="block"> <label className="block">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span> <span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input <input
@ -813,6 +822,7 @@ const SettingField = ({ label, value, onChange }) => (
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/> />
{hint ? <span className="mt-1 block text-[10px] leading-snug text-[var(--color-text-muted)]">{hint}</span> : null}
</label> </label>
); );

View File

@ -29,8 +29,8 @@ const OUTCOME_LABELS = {
confirmed_kept_previous: "Правка не доведена", confirmed_kept_previous: "Правка не доведена",
confirmed_silent: "Молчание", confirmed_silent: "Молчание",
no_answer: "Не взял", no_answer: "Не взял",
no_input_day: "Не нажал (день)", no_input_day: "Не выбрал день",
no_input_half: "Не нажал (время)", no_input_half: "Не выбрал время",
operator: "Менеджер", operator: "Менеджер",
opt_out: "Отказ", opt_out: "Отказ",
invalid_number: "Неверный номер", invalid_number: "Неверный номер",

View File

@ -10,6 +10,8 @@ import { Badge } from "../../UI/Badge";
import { supabase } from "../../../supabaseClient"; import { supabase } from "../../../supabaseClient";
import { useAuth } from "../../../context/AuthContext"; import { useAuth } from "../../../context/AuthContext";
import { TestCallPanel } from "./TestCallPanel"; import { TestCallPanel } from "./TestCallPanel";
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
// ── Кампании звонков ───────────────────────────────────────────────────────── // ── Кампании звонков ─────────────────────────────────────────────────────────
const CAMPAIGNS = [ const CAMPAIGNS = [
@ -25,10 +27,8 @@ const OUTCOME_META = {
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" }, confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" }, confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
}; };
@ -64,8 +64,8 @@ const slotText = (l) => {
return null; return null;
}; };
const SettingField = ({ label, value, onChange }) => ( const SettingField = ({ label, value, onChange, hint }) => (
<label className="block"> <label className="block" title={hint || undefined}>
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span> <span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input <input
type="text" type="text"
@ -284,14 +284,40 @@ export const AutocallChannelTab = () => {
)} )}
</div> </div>
{/* Самовывоз — отдельный выключатель: звонки клиентам, которые забирают со склада */}
<div className={`mb-4 rounded-xl border p-3 ${settings.pickup_calls_enabled ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" : "border-[var(--color-border)] bg-[var(--color-surface)]"}`}>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">🏪 Самовывоз</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
{settings.pickup_calls_enabled
? "Робот звонит и по самовывозу: предлагает забрать завтра или послезавтра"
: "Звонки по самовывозу выключены — звоним только по доставке"}
</div>
</div>
<button
type="button"
onClick={() => updateSetting("pickup_calls_enabled", !settings.pickup_calls_enabled)}
className={`relative h-7 w-12 shrink-0 rounded-full transition ${settings.pickup_calls_enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.pickup_calls_enabled ? "left-[22px]" : "left-0.5"}`} />
</button>
</label>
</div>
{/* Интервалы и попытки */} {/* Интервалы и попытки */}
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">📊 Интервалы и попытки</div> <div className="mb-1 text-xs font-semibold text-[var(--color-text)]">📊 Интервалы и попытки</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<SettingField label="Пауза после SMS (мин)" value={settings.delay_after_sms_minutes} onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)} /> <SettingField
label="Пауза после SMS / попытки (мин)"
value={settings.delay_after_sms_minutes}
hint="Столько ждём и после отправленного SMS, и после последней неудачной попытки отправки: если приглашение не ушло, робот всё равно позвонит."
onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)}
/>
<SettingField label="Между попытками (мин)" value={settings.retry_interval_minutes} onChange={(v) => updateSetting("retry_interval_minutes", parseInt(v) || 120)} /> <SettingField label="Между попытками (мин)" value={settings.retry_interval_minutes} onChange={(v) => updateSetting("retry_interval_minutes", parseInt(v) || 120)} />
<SettingField label="Попыток в день" value={settings.attempts_per_day} onChange={(v) => updateSetting("attempts_per_day", parseInt(v) || 3)} /> <SettingField label="Попыток в день" value={settings.attempts_per_day} onChange={(v) => updateSetting("attempts_per_day", parseInt(v) || 3)} />
<SettingField label="Всего попыток" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 5)} /> <SettingField label="Всего попыток" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 5)} />
<SettingField label="«Не нажал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} /> <SettingField label="«Не выбрал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} />
<SettingField label="Попытки след. дня (HH:MM,HH:MM)" value={settings.next_day_attempt_times || ""} onChange={(v) => updateSetting("next_day_attempt_times", v)} /> <SettingField label="Попытки след. дня (HH:MM,HH:MM)" value={settings.next_day_attempt_times || ""} onChange={(v) => updateSetting("next_day_attempt_times", v)} />
</div> </div>
@ -346,6 +372,12 @@ export const AutocallChannelTab = () => {
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */} {/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
{isMegaAdmin && <TestCallPanel />} {isMegaAdmin && <TestCallPanel />}
{/* ── Локации: где звонит робот ──────────────────────────────────── */}
<AutocallLocationsPanel />
{/* ── Исключения автодозвона ─────────────────────────────────────── */}
<AutocallExclusionsPanel />
{/* ── Журнал звонков ─────────────────────────────────────────────── */} {/* ── Журнал звонков ─────────────────────────────────────────────── */}
<Panel className="p-4"> <Panel className="p-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -404,6 +436,7 @@ export const AutocallChannelTab = () => {
<div className="px-3 py-1.5 text-[var(--color-text)]"> <div className="px-3 py-1.5 text-[var(--color-text)]">
{fmtPhone(l.customer_phone)} {fmtPhone(l.customer_phone)}
{l.was_test_mode && <div className="text-[10px] text-[var(--color-warning)]">🧪 тест</div>} {l.was_test_mode && <div className="text-[10px] text-[var(--color-warning)]">🧪 тест</div>}
<div className="text-[10px] text-[var(--color-text-muted)]">{l.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</div>
</div> </div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.attempt_no}</div> <div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.attempt_no}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.duration_sec ? `${l.duration_sec}с` : "—"}</div> <div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.duration_sec ? `${l.duration_sec}с` : "—"}</div>

View File

@ -0,0 +1,226 @@
/**
* @file AutocallExclusionsPanel.jsx
* @description Панель «Исключения автодозвона» в настройках коммуникаций.
* Логист/админ добавляет префиксы или точные номера, на которые робот не звонит
* (например, МТС-диапазон, пока не починят DTMF). Правило можно отключить галочкой
* или удалить — и звонки по нему возобновятся.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../../UI/Panel";
import { Badge } from "../../UI/Badge";
import { supabase } from "../../../supabaseClient";
import { useAuth } from "../../../context/AuthContext";
const fmtPhone = (phone) => {
if (!phone) return "—";
const d = String(phone).replace(/\D/g, "");
if (d.length === 11 && d.startsWith("7")) {
return `+7 (${d.slice(1, 4)}) ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9)}`;
}
return phone;
};
export const AutocallExclusionsPanel = () => {
const { user } = useAuth();
const canManage = ["mega_admin", "admin", "logistician"].includes(user?.role);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [ruleType, setRuleType] = useState("prefix");
const [value, setValue] = useState("");
const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data, error } = await supabase
.from("autocall_exclusions")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
setItems(data || []);
} catch (e) {
setError(e.message || String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleAdd = async () => {
const v = String(value || "").replace(/\D/g, "");
if (!v) { setError("Введите номер или префикс (только цифры)"); return; }
setSaving(true);
setError(null);
try {
const { error } = await supabase.from("autocall_exclusions").insert({
rule_type: ruleType,
value: v,
reason: reason.trim(),
active: true,
created_by: user?.email || null,
});
if (error) throw error;
setValue(""); setReason("");
await load();
} catch (e) {
setError(e.message || String(e));
} finally {
setSaving(false);
}
};
const handleToggle = async (item) => {
try {
const { error } = await supabase
.from("autocall_exclusions")
.update({ active: !item.active })
.eq("id", item.id);
if (error) throw error;
setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, active: !item.active } : i)));
} catch (e) {
setError(e.message || String(e));
}
};
const handleDelete = async (item) => {
try {
const { error } = await supabase.from("autocall_exclusions").delete().eq("id", item.id);
if (error) throw error;
setItems((prev) => prev.filter((i) => i.id !== item.id));
} catch (e) {
setError(e.message || String(e));
}
};
return (
<Panel className="p-5">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text)]">🚫 Исключения автодозвона</h3>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
Номера и префиксы, на которые робот не звонит. Отключите или удалите правило — звонки возобновятся.
</p>
</div>
{canManage && (
<button
type="button"
onClick={load}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]"
>
Обновить
</button>
)}
</div>
{error && (
<div className="mb-3 rounded-xl border border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-2.5 text-xs text-[var(--color-danger)]">
{error}
</div>
)}
{/* Добавление */}
{canManage && (
<div className="mb-4 flex flex-wrap items-end gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div>
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">Тип</span>
<select
value={ruleType}
onChange={(e) => setRuleType(e.target.value)}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
<option value="prefix">Префикс (все номера с этих цифр)</option>
<option value="phone">Точный номер</option>
</select>
</div>
<div className="min-w-[140px] flex-1">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">
{ruleType === "prefix" ? "Префикс (напр. 7918)" : "Номер (напр. 79189521296)"}
</span>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={ruleType === "prefix" ? "7918" : "79189521296"}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</div>
<div className="min-w-[160px] flex-1">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">Причина (для карточки)</span>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="МТС — кнопки не проходят"
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</div>
<button
type="button"
onClick={handleAdd}
disabled={saving}
className="rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{saving ? "…" : "+ Добавить"}
</button>
</div>
)}
{/* Список */}
{loading ? (
<div className="animate-pulse text-xs text-[var(--color-text-muted)]">Загрузка…</div>
) : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-4 text-center text-xs text-[var(--color-text-muted)]">
Исключений нет — робот звонит на все номера.
</div>
) : (
<div className="space-y-1.5">
{items.map((item) => (
<div
key={item.id}
className={`flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs ${
item.active ? "" : "opacity-50"
}`}
>
<button
type="button"
onClick={() => handleToggle(item)}
disabled={!canManage}
className={`relative h-5 w-9 shrink-0 rounded-full transition ${item.active ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
title={item.active ? "Отключить правило" : "Включить правило"}
>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition ${item.active ? "left-[18px]" : "left-0.5"}`} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-[var(--color-text)]">
{item.rule_type === "prefix" ? `📡 ${item.value}…` : `📞 ${fmtPhone(item.value)}`}
</span>
<Badge tone={item.active ? "warning" : "neutral"}>
{item.active ? "не звоним" : "выключено"}
</Badge>
</div>
{item.reason && (
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">{item.reason}</div>
)}
</div>
{canManage && (
<button
type="button"
onClick={() => handleDelete(item)}
className="shrink-0 rounded-lg px-2 py-1 text-[11px] text-[var(--color-danger)] hover:bg-[var(--color-surface-strong)]"
title="Удалить правило"
>
✕
</button>
)}
</div>
))}
</div>
)}
</Panel>
);
};

View File

@ -0,0 +1,151 @@
/**
* @file AutocallLocationsPanel.jsx
* @description Панель «Локации» в настройках автодозвона.
* Логист/админ выключает звонки по конкретным городам (например, по Ялте звонки
* не делаем — там работают SMS-уведомления). Правила хранятся в общих городских
* настройках рассылок, но только для звонков (campaign_type='autocall_slot'),
* поэтому SMS по этому городу продолжают уходить.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../../UI/Panel";
import { Badge } from "../../UI/Badge";
import { supabase } from "../../../supabaseClient";
import { useAuth } from "../../../context/AuthContext";
export const AutocallLocationsPanel = () => {
const { user } = useAuth();
const canManage = ["mega_admin", "admin", "logistician"].includes(user?.role);
const [cities, setCities] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data, error } = await supabase.rpc("autocall_city_options");
if (error) throw error;
setCities(data || []);
} catch (e) {
setError(e.message || String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleToggle = async (city) => {
const next = !city.calls_enabled;
setBusy(city.city);
setError(null);
try {
// Пишем в таблицу переключателей звонков: у SMS по этому городу
// своя настройка, и она не меняется.
const { error } = await supabase
.from("autocall_city_rules")
.upsert(
{
city: city.city,
calls_enabled: next,
note: next ? "" : `Звонки по ${city.city} выключены`,
updated_by: user?.email || null,
updated_at: new Date().toISOString(),
},
{ onConflict: "city" }
);
if (error) throw error;
setCities((prev) =>
prev.map((c) => (c.city === city.city ? { ...c, calls_enabled: next } : c))
);
} catch (e) {
setError(e.message || String(e));
} finally {
setBusy(null);
}
};
return (
<Panel className="p-5">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text)]">📍 Локации</h3>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
Где звонит робот. Выключенный город звонки не получает — на SMS это не влияет,
уведомления продолжают уходить.
</p>
</div>
{canManage && (
<button
type="button"
onClick={load}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]"
>
Обновить
</button>
)}
</div>
{error && (
<div className="mb-3 rounded-xl border border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-2.5 text-xs text-[var(--color-danger)]">
{error}
</div>
)}
{loading ? (
<div className="animate-pulse text-xs text-[var(--color-text-muted)]">Загрузка…</div>
) : cities.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-4 text-center text-xs text-[var(--color-text-muted)]">
Городов пока нет.
</div>
) : (
<div className="space-y-1.5">
{cities.map((c) => (
<div
key={c.city}
className={`flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs ${
c.calls_enabled ? "" : "opacity-60"
}`}
>
<button
type="button"
onClick={() => handleToggle(c)}
disabled={!canManage || busy === c.city}
className={`relative h-5 w-9 shrink-0 rounded-full transition ${
c.calls_enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
title={c.calls_enabled ? "Выключить звонки в этот город" : "Включить звонки в этот город"}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition ${
c.calls_enabled ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-[var(--color-text)]">{c.city}</span>
<Badge tone={c.calls_enabled ? "accent" : "neutral"}>
{c.calls_enabled ? "звоним" : "не звоним"}
</Badge>
{c.global_blocked && (
<Badge tone="danger">город исключён полностью</Badge>
)}
</div>
{c.note && (
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">{c.note}</div>
)}
{c.global_blocked && (
<div className="mt-0.5 text-[11px] text-[var(--color-danger)]">
В правилах города стоит полное исключение — SMS по нему тоже не уходят.
</div>
)}
</div>
</div>
))}
</div>
)}
</Panel>
);
};

View File

@ -18,10 +18,8 @@ const OUTCOME_META = {
confirmed_kept_previous: { label: "Правка не доведена", tone: "warning", icon: "🔁" }, confirmed_kept_previous: { label: "Правка не доведена", tone: "warning", icon: "🔁" },
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" }, confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
}; };
@ -49,6 +47,7 @@ const fmtTime = (ts) => {
export const TestCallPanel = () => { export const TestCallPanel = () => {
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [callType, setCallType] = useState("dlv"); // dlv = доставка, pkp = самовывоз
const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error
const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста
const [startedAt, setStartedAt] = useState(null); const [startedAt, setStartedAt] = useState(null);
@ -90,6 +89,7 @@ export const TestCallPanel = () => {
order_group_id: null, order_group_id: null,
phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)), phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)),
scenario: "A", scenario: "A",
call_type: callType,
is_test_call: true, is_test_call: true,
}); });
if (error) throw error; if (error) throw error;
@ -152,6 +152,29 @@ export const TestCallPanel = () => {
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню. Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
</p> </p>
{/* Тип сценария: доставка или самовывоз — от него зависит озвучка робота */}
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">Сценарий:</span>
{[
{ key: "dlv", label: "🚚 Доставка" },
{ key: "pkp", label: "🏪 Самовывоз" },
].map((t) => (
<button
key={t.key}
type="button"
onClick={() => setCallType(t.key)}
disabled={status === "queueing" || status === "queued" || status === "dialing"}
className={`rounded-full px-3 py-1 text-xs font-medium transition disabled:opacity-50 ${
callType === t.key
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<input <input
type="tel" type="tel"
@ -227,6 +250,7 @@ export const TestCallPanel = () => {
return ( return (
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]"> <div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span> <span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
<span className="text-[10px] text-[var(--color-text-muted)]">{t.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</span>
<span className={`text-[var(--color-text-muted)]`}>{tm.icon} {tm.label}</span> <span className={`text-[var(--color-text-muted)]`}>{tm.icon} {tm.label}</span>
{slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>} {slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>}
<span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span> <span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span>

View File

@ -36,6 +36,26 @@ const getCrimeaHour = (referenceDate = new Date()) => {
); );
}; };
// Минуты от полуночи по Симферополю — чтобы сравнивать с настройками склада.
const getCrimeaMinutes = (referenceDate = new Date()) => {
const parts = new Intl.DateTimeFormat("ru-RU", {
timeZone: DELIVERY_TIMEZONE,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(referenceDate);
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0", 10);
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0", 10);
return hour * 60 + minute;
};
// "15:30" → 930
const timeToMinutes = (value, fallback) => {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
if (!match) return fallback;
return Number(match[1]) * 60 + Number(match[2]);
};
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => { const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
if (!dateKey) return false; if (!dateKey) return false;
const d = new Date(`${dateKey}T12:00:00Z`); const d = new Date(`${dateKey}T12:00:00Z`);
@ -53,64 +73,67 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
return next; return next;
}; };
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => { /**
const todayKey = getCrimeaTodayKey(referenceDate); * Слоты самовывоза.
const hour = getCrimeaHour(referenceDate); *
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays); * Окно считаем от дня готовности (referenceDate — день попадания в базу, он же
* день отправки ссылки): забрать можно в день готовности и два следующих дня,
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
*/
export const getPickupSlots = (
referenceDate = new Date(),
pickupDays = [1, 2, 3, 4, 5],
now = new Date(),
schedule = {},
) => {
const todayKey = getCrimeaTodayKey(now);
const readyKey = getCrimeaTodayKey(referenceDate);
// Часы склада из админки («График работы»): граница половины дня и отсечка
// «сегодня». Дефолты — прежнее поведение (12:00 и «весь день»).
const halfEndMinutes = timeToMinutes(schedule.halfEnd, 12 * 60);
const cutoffMinutes = timeToMinutes(schedule.sameDayCutoff, 24 * 60);
const nowMinutes = getCrimeaMinutes(now);
// Окно считается рабочими днями: день готовности + два следующих рабочих дня
// (пятница → пт, пн, вт). Прошедшие дни не показываем.
const windowKeys = [];
let windowDay = readyKey;
let guard = 0;
while (windowKeys.length < 3 && guard < 30) {
if (isAllowedPickupDay(windowDay, pickupDays)) windowKeys.push(windowDay);
windowDay = addDaysKey(windowDay, 1);
guard += 1;
}
const allowedKeys = windowKeys.filter((key) => key >= todayKey);
const PICKUP_TYPES = ["today", "tomorrow", "dayAfter"];
const slots = []; const slots = [];
if (isTodayWorkday) { const addSlot = (dateKey, half, label, pickupType) => {
if (hour < 12) {
slots.push({
id: `pickup-${todayKey}-first`,
date: todayKey,
time: "Первая половина дня",
label: "Сегодня",
pickupType: "today",
});
}
slots.push({ slots.push({
id: `pickup-${todayKey}-second`, id: `pickup-${dateKey}-${half}`,
date: todayKey, date: dateKey,
time: "Вторая половина дня", time: half === "first" ? "Первая половина дня" : "Вторая половина дня",
label: "Сегодня", label,
pickupType: "today", pickupType,
}); });
} };
const tomorrow = addDaysKey(todayKey, 1); allowedKeys.forEach((dateKey, index) => {
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays); const isToday = dateKey === todayKey;
slots.push({ const label = isToday
id: `pickup-${tomorrowWorkday}-first`, ? "Сегодня"
date: tomorrowWorkday, : (getDeliveryRelativeDayLabel(dateKey, now) || `День ${index + 1}`);
time: "Первая половина дня", if (isToday) {
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра", // После отсечки «сегодня» склад не успеет собрать заказ — вариант не показываем,
pickupType: "tomorrow", // первая половина доступна только до границы половины дня.
}); if (nowMinutes >= cutoffMinutes) return;
slots.push({ if (nowMinutes < halfEndMinutes) addSlot(dateKey, "first", label, "today");
id: `pickup-${tomorrowWorkday}-second`, addSlot(dateKey, "second", label, "today");
date: tomorrowWorkday, return;
time: "Вторая половина дня", }
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра", addSlot(dateKey, "first", label, PICKUP_TYPES[index] || "dayAfter");
pickupType: "tomorrow", addSlot(dateKey, "second", label, PICKUP_TYPES[index] || "dayAfter");
});
const dayAfter = addDaysKey(tomorrowWorkday, 1);
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
slots.push({
id: `pickup-${dayAfterWorkday}-first`,
date: dayAfterWorkday,
time: "Первая половина дня",
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
pickupType: "dayAfter",
});
slots.push({
id: `pickup-${dayAfterWorkday}-second`,
date: dayAfterWorkday,
time: "Вторая половина дня",
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
pickupType: "dayAfter",
}); });
return slots; return slots;
@ -156,8 +179,14 @@ export const PickupSlotsPicker = ({
selectedSlotId, selectedSlotId,
referenceDate = new Date(), referenceDate = new Date(),
pickupDays = [1, 2, 3, 4, 5], pickupDays = [1, 2, 3, 4, 5],
schedule = {},
}) => { }) => {
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]); const halfEnd = schedule?.halfEnd;
const sameDayCutoff = schedule?.sameDayCutoff;
const slots = React.useMemo(
() => getPickupSlots(referenceDate, pickupDays, new Date(), { halfEnd, sameDayCutoff }),
[referenceDate, pickupDays, halfEnd, sameDayCutoff],
);
if (!slots.length) { if (!slots.length) {
return ( return (

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { getPickupSlots } from "./PickupSlotsPicker";
// Склад: пн–пт, половина дня до 13:00, после 15:30 «сегодня» не предлагаем.
const SCHEDULE = { halfEnd: "13:00", sameDayCutoff: "15:30" };
const DAYS = [1, 2, 3, 4, 5];
const crimeaDate = (iso, hour, minute = 0) => new Date(`${iso}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+03:00`);
// Пятница 18.09.2026, понедельник 21.09.2026, вторник 22.09.2026.
const FRIDAY = "2026-09-18";
const MONDAY = "2026-09-21";
const TUESDAY = "2026-09-22";
const datesOf = (slots) => [...new Set(slots.map((s) => s.date))];
describe("PickupSlotsPicker: окно самовывоза рабочими днями", () => {
it("готов в пятницу утром → пятница, понедельник, вторник", () => {
const slots = getPickupSlots(crimeaDate(FRIDAY, 9), DAYS, crimeaDate(FRIDAY, 9), SCHEDULE);
expect(datesOf(slots)).toEqual([FRIDAY, MONDAY, TUESDAY]);
});
it("готов в понедельник → понедельник, вторник, среда", () => {
const slots = getPickupSlots(crimeaDate(MONDAY, 10), DAYS, crimeaDate(MONDAY, 10), SCHEDULE);
expect(datesOf(slots)).toEqual([MONDAY, TUESDAY, "2026-09-23"]);
});
it("после отсечки 15:30 сегодня не предлагаем — остаются следующие дни окна", () => {
const slots = getPickupSlots(crimeaDate(MONDAY, 16), DAYS, crimeaDate(MONDAY, 16), SCHEDULE);
expect(datesOf(slots)).toEqual([TUESDAY, "2026-09-23"]);
});
it("после 13:00 первая половина сегодняшнего дня недоступна", () => {
const slots = getPickupSlots(crimeaDate(MONDAY, 14), DAYS, crimeaDate(MONDAY, 14), SCHEDULE);
const today = slots.filter((s) => s.date === MONDAY);
expect(today.map((s) => s.time)).toEqual(["Вторая половина дня"]);
});
it("готов в субботу (склад закрыт) → три рабочих дня с понедельника", () => {
const saturday = "2026-09-19";
const slots = getPickupSlots(crimeaDate(saturday, 11), DAYS, crimeaDate(saturday, 11), SCHEDULE);
expect(datesOf(slots)).toEqual([MONDAY, TUESDAY, "2026-09-23"]);
});
it("окно прошло (готов неделю назад) → предложить нечего", () => {
const slots = getPickupSlots(crimeaDate("2026-09-10", 10), DAYS, crimeaDate(FRIDAY, 10), SCHEDULE);
expect(slots).toEqual([]);
});
});

View File

@ -26,6 +26,7 @@ import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading"; import { SkeletonPage } from "../UI/Loading";
import { Pagination } from "../UI/Pagination"; import { Pagination } from "../UI/Pagination";
import { OrderFilters } from "../orders/OrderFilters"; import { OrderFilters } from "../orders/OrderFilters";
import { SmsProblemNotice } from "./SmsProblemNotice";
import { CallStatusBadge } from "../UI/CallStatusBadge"; import { CallStatusBadge } from "../UI/CallStatusBadge";
import { formatDate, formatDateTime } from "../../utils/formatters"; import { formatDate, formatDateTime } from "../../utils/formatters";
@ -299,6 +300,7 @@ const isFilterActive = (filters) => {
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true; if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true; if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
if (filters.smsStatus && filters.smsStatus !== "all") return true; if (filters.smsStatus && filters.smsStatus !== "all") return true;
if (filters.autocallStatus && filters.autocallStatus !== "all") return true;
if (filters.dateFrom) return true; if (filters.dateFrom) return true;
if (filters.dateTo) return true; if (filters.dateTo) return true;
return false; return false;
@ -404,7 +406,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
// Reset to page 1 when filters change // Reset to page 1 when filters change
React.useEffect(() => { React.useEffect(() => {
setPage(1); setPage(1);
}, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.dateFrom, filters.dateTo]); }, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.autocallStatus, filters.dateFrom, filters.dateTo]);
const totalGroups = rankedGroups.length; const totalGroups = rankedGroups.length;
const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));
@ -508,6 +510,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
)} )}
</Panel> </Panel>
{/* Причина, по которой SMS не уходят — до таблицы, чтобы не искать её в логе */}
<SmsProblemNotice orderGroupIds={filteredGroups.map((g) => g.id)} />
{!totalGroups ? ( {!totalGroups ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]"> <div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
По этому поиску ничего не найдено. По этому поиску ничего не найдено.

View File

@ -0,0 +1,94 @@
/**
* @file SmsProblemNotice.jsx
* @description Объясняет, что происходит с отправкой SMS — над таблицей наборов.
*
* В таблице видно только «SMS не отправлено», а причина лежит в логе отправок
* цифрой (например, 201 — на счёте SMS-сервиса закончились средства). Блок
* читает последние неудачные отправки, спрашивает баланс счёта через функцию
* sms-balance и показывает вывод словами:
* • средств нет — красное предупреждение с причиной;
* • счёт пополнен — короткая строка «SMS снова уходят» (гаснет сама через неделю);
* • денег мало — предупреждение заранее, до остановки отправки.
*
* Крестик закрывает блок: решение запоминается в браузере по «отпечатку»
* состояния, поэтому та же самая проблема больше не всплывает, а новая — показывается.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { supabase } from "../../supabaseClient";
import { summarizeSmsFailures } from "../../services/smsProblemSummary";
import { fetchSmsBalance } from "../../services/smsBalance";
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
const LOOKBACK_DAYS = 30;
const FAILED_STATUSES = ["send_failed", "error", "limit_exceeded"];
const BALANCE_LOW_RUB = 100;
const DISMISS_STORAGE_KEY = "supersam_sms_problem_dismissed";
const readDismissed = () => {
try {
return window.localStorage.getItem(DISMISS_STORAGE_KEY) || "";
} catch {
return "";
}
};
const writeDismissed = (signature) => {
try {
window.localStorage.setItem(DISMISS_STORAGE_KEY, signature);
} catch {
// Приватный режим — просто не запоминаем.
}
};
/** Баланс счёта SMS-сервиса: ключ живёт только на сервере, поэтому спрашиваем функцию. */
const fetchBalance = async () => {
const live = await fetchSmsBalance();
return live ? { balance: live.balance, lowBalance: live.lowBalance } : null;
};
export function SmsProblemNotice({ orderGroupIds = [], className = "" }) {
const [summary, setSummary] = useState(null);
const [dismissed, setDismissed] = useState(readDismissed);
const visibleKey = useMemo(() => orderGroupIds.join(","), [orderGroupIds]);
useEffect(() => {
let alive = true;
(async () => {
try {
const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
const [{ data, error }, balanceInfo] = await Promise.all([
supabase
.from("sms_campaign_log")
.select("order_group_id, campaign_type, status, sms_code, error_message, created_at")
.in("status", FAILED_STATUSES)
.gte("created_at", since)
.order("created_at", { ascending: false })
.limit(1000),
fetchBalance(),
]);
if (error) throw error;
if (!alive) return;
setSummary(summarizeSmsFailures(data || [], orderGroupIds, {
balance: balanceInfo?.balance ?? null,
lowBalance: balanceInfo?.lowBalance ?? BALANCE_LOW_RUB,
}));
} catch {
// Лог может быть недоступен для роли — тогда просто ничего не показываем.
if (alive) setSummary(null);
}
})();
return () => {
alive = false;
};
}, [visibleKey]); // eslint-disable-line react-hooks/exhaustive-deps
const handleClose = useCallback(() => {
if (!summary) return;
writeDismissed(summary.signature);
setDismissed(summary.signature);
}, [summary]);
const visible = summary && summary.signature !== dismissed ? summary : null;
return <SmsProblemNoticeView summary={visible} onClose={handleClose} className={className} />;
}

View File

@ -0,0 +1,80 @@
/**
* @file SmsProblemNotice.test.jsx
* @description Блок про SMS: причина словами, «всё наладилось», предупреждение о балансе
* и крестик, которым блок можно закрыть.
*/
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it, expect, vi } from "vitest";
import { SmsProblemNoticeView } from "./SmsProblemNoticeView";
/** В русской локали разделитель разрядов — неразрывный пробел. */
const plain = (html) => String(html).replace(/[\u00a0\u202f]/g, " ");
const base = {
reason: "на счёте SMS-сервиса закончились средства",
messages: 42,
topMessages: 42,
groups: 8,
affectedInTable: 3,
hasVisibleRows: true,
hasFundsReason: true,
firstAt: "2026-09-10T10:06:11Z",
lastAt: "2026-09-13T06:03:03Z",
balance: 12717.45,
balanceKnown: true,
lowBalanceThreshold: 100,
};
describe("SmsProblemNoticeView", () => {
it("при нехватке средств пишет причину и что делать", () => {
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} />);
expect(html).toContain("SMS не отправлено: на счёте SMS-сервиса закончились средства");
expect(html).toContain("8 заказов");
expect(html).toContain("42 сообщения");
expect(html).toContain("В таблице ниже — 3 из них.");
expect(html).toContain("Пополните счёт SMS-сервиса");
});
it("после пополнения счёта красное предупреждение сменяется спокойным «снова уходят»", () => {
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "resolved" }} />);
expect(html).toContain("SMS снова уходят");
expect(plain(html)).toContain("на счёте есть деньги (12 717,45 ₽)");
expect(html).not.toContain("Пополните счёт");
expect(html).toContain("Эти сообщения не ушли с 10.09 по 13.09 из-за нехватки средств");
expect(html).not.toContain("В таблице ниже");
});
it("предупреждает о низком балансе, когда ошибок ещё нет", () => {
const html = renderToStaticMarkup(
<SmsProblemNoticeView summary={{ ...base, kind: "low_balance", reason: null, groups: 0, messages: 0, balance: 55 }} />,
);
expect(plain(html)).toContain("На счёте SMS-сервиса мало денег: 55 ₽");
expect(html).toContain("пополните счёт заранее");
});
it("для другой причины подсказка другая", () => {
const html = renderToStaticMarkup(
<SmsProblemNoticeView summary={{ ...base, kind: "other", reason: "неверный номер получателя", hasFundsReason: false, groups: 1 }} />,
);
expect(html).toContain("SMS не отправлено: неверный номер получателя");
expect(html).toContain("1 заказ");
expect(html).toContain("разделе «SMS-рассылки»");
});
it("крестик есть, когда блок можно закрыть", () => {
const withClose = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} onClose={vi.fn()} />);
const withoutClose = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} />);
expect(withClose).toContain("Скрыть сообщение");
expect(withoutClose).not.toContain("Скрыть сообщение");
});
it("без сводки ничего не рисуется", () => {
expect(renderToStaticMarkup(<SmsProblemNoticeView summary={null} />)).toBe("");
});
});

View File

@ -0,0 +1,136 @@
/**
* @file SmsProblemNoticeView.jsx
* @description Разметка блока про SMS: причина, «всё наладилось» и предупреждение о низком балансе.
*
* Отдельно от загрузки данных: блок рисует то, что посчитал summarizeSmsFailures.
* Так разметку можно проверить без сети и Supabase. Крестик закрывает блок —
* состояние запоминает сам блок данных (SmsProblemNotice).
*/
import React from "react";
import { plural } from "../../services/smsProblemSummary";
const fmtDay = (iso) => {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
} catch {
return "";
}
};
const fmtMoney = (value) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
return `${n.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ₽`;
};
const TONES = {
funds: {
icon: "⛔",
box: "border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)]",
chip: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
text: "text-[var(--color-danger)]",
},
low_balance: {
icon: "⚠️",
box: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]",
chip: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]",
text: "text-[var(--color-warning)]",
},
other: {
icon: "⚠️",
box: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]",
chip: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]",
text: "text-[var(--color-warning)]",
},
resolved: {
icon: "✅",
box: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
chip: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
text: "text-[var(--color-text-muted)]",
},
};
export const SmsProblemNoticeView = ({ summary, onClose, className = "" }) => {
if (!summary) return null;
const tone = TONES[summary.kind] || TONES.other;
const period = summary.firstAt
? summary.firstAt === summary.lastAt
? `за ${fmtDay(summary.firstAt)}`
: `с ${fmtDay(summary.firstAt)} по ${fmtDay(summary.lastAt)}`
: "";
const groupsText = `${summary.groups} ${plural(summary.groups, "заказ", "заказа", "заказов")}`;
const messagesText = `${summary.messages} ${plural(summary.messages, "сообщение", "сообщения", "сообщений")}`;
const tableText =
summary.affectedInTable > 0
? `В таблице ниже — ${summary.affectedInTable} из них.`
: summary.hasVisibleRows
? "В текущей выборке таблицы таких заказов нет."
: "";
const headline =
summary.kind === "funds"
? `SMS не отправлено: ${summary.reason}`
: summary.kind === "low_balance"
? `На счёте SMS-сервиса мало денег: ${fmtMoney(summary.balance)}`
: summary.kind === "resolved"
? `SMS снова уходят: на счёте есть деньги (${fmtMoney(summary.balance)})`
: `SMS не отправлено: ${summary.reason}`;
const hint =
summary.kind === "funds"
? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно."
: summary.kind === "low_balance"
? `Осталось меньше ${fmtMoney(summary.lowBalanceThreshold)} — пополните счёт заранее, иначе отправка остановится.`
: summary.kind === "resolved"
? `Эти сообщения не ушли ${period} из-за нехватки средств — они остались в разделе «SMS-рассылки». Новые отправки идут нормально, делать ничего не нужно.`
: "Проверьте настройки отправки SMS в разделе «SMS-рассылки».";
// Для «всё наладилось» не подмешиваем счётчик из таблицы — он там уже не важен.
const showTableText = summary.kind !== "resolved";
return (
<div className={["rounded-[28px] border p-4 text-sm", tone.box, className].join(" ")}>
<div className="flex flex-wrap items-start gap-2">
<span className="text-base leading-none">{tone.icon}</span>
<span className="min-w-0 flex-1 font-semibold text-[var(--color-text)]">{headline}</span>
{summary.kind !== "low_balance" && (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${tone.chip}`}>
{groupsText}
</span>
)}
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Скрыть сообщение"
title="Скрыть сообщение"
className="-mr-1 -mt-1 rounded-full px-2 py-0.5 text-base leading-none text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
×
</button>
)}
</div>
{summary.kind !== "low_balance" && (
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
{messagesText}
{period ? ` · ${period}` : ""}
{showTableText && tableText ? ` · ${tableText}` : ""}
</div>
)}
<div className={`mt-2 text-xs ${summary.kind === "resolved" ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}`}>
{hint}
</div>
{summary.kind !== "resolved" && (
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
Автодозвон из-за этого не останавливается: робот позвонит клиенту через паузу после последней
попытки отправки — столько же, сколько настроено в «Пауза после SMS (мин)».
</div>
)}
</div>
);
};

View File

@ -3,19 +3,20 @@
* @description Блок «Автодозвон» в карточке заказа. * @description Блок «Автодозвон» в карточке заказа.
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить». * Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
*/ */
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
import { describeCallProcess, isOrderAgreed } from "../../services/autocallProcess";
import { isAutocallPossible } from "../../services/autocallAvailability";
const OUTCOME_META = { const OUTCOME_META = {
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" }, confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" }, confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" }, confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" }, no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" }, no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" }, no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
}; };
@ -43,13 +44,16 @@ const slotText = (l) => {
export const AutocallStatusCard = ({ order, userRole }) => { export const AutocallStatusCard = ({ order, userRole }) => {
const [calls, setCalls] = useState([]); const [calls, setCalls] = useState([]);
const [queueItem, setQueueItem] = useState(null);
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState(false); const [toggling, setToggling] = useState(false);
const [queued, setQueued] = useState(false); const [sending, setSending] = useState(false);
const canManage = ["mega_admin", "logistician"].includes(userRole); const canManage = ["mega_admin", "logistician"].includes(userRole);
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false; const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall"; const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null;
const loadCalls = useCallback(async () => { const loadCalls = useCallback(async () => {
if (!order?.id) return; if (!order?.id) return;
@ -60,7 +64,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
.select("*") .select("*")
.eq("order_group_id", order.id) .eq("order_group_id", order.id)
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(5); .limit(10);
if (error) throw error; if (error) throw error;
setCalls(data || []); setCalls(data || []);
} catch (e) { } catch (e) {
@ -70,23 +74,147 @@ export const AutocallStatusCard = ({ order, userRole }) => {
} }
}, [order?.id]); }, [order?.id]);
useEffect(() => { loadCalls(); }, [loadCalls]); // Последняя заявка «Позвонить сейчас» по этому заказу: без неё в карточке было
// не видно, ушёл ли запрос и что с ним стало.
const loadQueue = useCallback(async () => {
if (!order?.id) return;
try {
const { data } = await supabase
.from("autocall_queue")
.select("id, requested_at, processed_at, is_test_call, call_type, phone")
.eq("order_group_id", order.id)
.order("requested_at", { ascending: false })
.limit(1);
setQueueItem(data?.[0] || null);
} catch {
// silent
}
}, [order?.id]);
// Настройки кампании: рабочие часы/дни и тестовый режим — из них понятно,
// когда именно робот возьмёт заявку.
const loadSettings = useCallback(async () => {
try {
const { data } = await supabase
.from("sms_campaign_settings")
.select("enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days")
.eq("campaign_type", "autocall_slot")
.maybeSingle();
setSettings(data || null);
} catch {
// silent
}
}, []);
useEffect(() => {
loadCalls();
loadQueue();
loadSettings();
}, [loadCalls, loadQueue, loadSettings]);
// Звонок, который робот сделал по нашей заявке (в пределах 15 минут после неё).
const callAfter = useMemo(() => {
if (!queueItem?.requested_at) return null;
const from = new Date(queueItem.requested_at).getTime() - 1000;
const to = from + 15 * 60 * 1000;
return (
calls.find((c) => {
const at = new Date(c.created_at).getTime();
return at >= from && at <= to;
}) || null
);
}, [calls, queueItem]);
// Заявка свежая (сутки) — иначе в карточке висел бы вчерашний процесс.
const recentQueue = useMemo(() => {
if (!queueItem?.requested_at) return null;
return Date.now() - new Date(queueItem.requested_at).getTime() <= 24 * 60 * 60 * 1000 ? queueItem : null;
}, [queueItem]);
// Заявка висит непринятой больше часа в рабочее время — планировщик молча
// пропускает её (окно звонка закрыто: по графику роботу нечего предлагать).
// Показываем честную причину вместо «в очереди».
const callUnreachable = useMemo(() => {
if (!recentQueue || recentQueue.processed_at || callAfter) return false;
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
if (age < 60 * 60 * 1000) return false;
// Рабочее время сейчас?
const workDays = (settings?.work_days || "").toString().split(",").map((s) => parseInt(s, 10)).filter(Number.isFinite);
const startHour = Number(settings?.work_hours_start ?? 9);
const endHour = Number(settings?.work_hours_end ?? 20);
const msk = new Date(Date.now() + (3 * 60 + new Date().getTimezoneOffset()) * 60000);
const dayOk = workDays.length === 0 || workDays.includes(msk.getDay());
const hourOk = msk.getHours() >= startHour && msk.getHours() < endHour;
return dayOk && hourOk;
}, [recentQueue, callAfter, settings]);
// Можно ли вообще выбрать автодозвон по этому заказу? Считаем по окну
// звонка (те же правила, что у планировщика). Пока окно закрыто — кнопку
// блокируем с объяснением; заявку в очереди НЕ трогаем и НЕ закрываем.
const [availability, setAvailability] = useState(null);
useEffect(() => {
let cancelled = false;
isAutocallPossible(order).then((res) => {
if (!cancelled) setAvailability(res);
});
return () => { cancelled = true; };
}, [order?.id, order?.delivery_type, order?.created_at]);
const autocallBlocked = availability ? !availability.possible : false;
// Заказ уже согласован — дату выбрал клиент (в том числе в звонке) или поставил менеджер.
// 'manual_required' («нужен менеджер») сюда НЕ входит: там дату как раз не выбрали,
// и звонок робота допустим (см. isOrderAgreed и её тесты).
const orderAgreed = isOrderAgreed(order);
const process = useMemo(
() => describeCallProcess({
queueItem: recentQueue,
callAfter,
settings: {
...(settings || {}),
call_unreachable: callUnreachable,
order_agreed: orderAgreed,
},
}),
[recentQueue, callAfter, settings, callUnreachable, orderAgreed],
);
const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing";
// Пока заявку не отработали (и в пределах 15 минут) — подтягиваем состояние каждые 7 секунд.
useEffect(() => {
if (!processing || !recentQueue) return undefined;
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
if (age > 15 * 60 * 1000) return undefined;
const timer = setInterval(() => {
loadQueue();
loadCalls();
}, 7000);
return () => clearInterval(timer);
}, [processing, recentQueue, loadQueue, loadCalls]);
const handleCallNow = async () => { const handleCallNow = async () => {
if (!order?.id) return; if (!order?.id) return;
setQueued(true); setSending(true);
try { try {
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn) // Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn).
// Сценарий планировщик определит сам по дню добавления группы
// (день добавления → A: завтра/послезавтра; позже → B: только завтра).
const { error } = await supabase.from("autocall_queue").insert({ const { error } = await supabase.from("autocall_queue").insert({
order_group_id: order.id, order_group_id: order.id,
phone: order.customer_phone || order.customerPhone || "", phone: order.customer_phone || order.customerPhone || "",
scenario: "A", // Тип озвучки: по самовывозу робот говорит «забрать», по доставке — «доставить».
// Если тип в объекте не пришёл, планировщик определит его по заказу сам.
...((order.delivery_type || order.deliveryType)
? { call_type: (order.delivery_type || order.deliveryType) === "pickup" ? "pkp" : "dlv" }
: {}),
}); });
if (error) throw error; if (error) throw error;
setTimeout(() => setQueued(false), 3000); await loadQueue();
} catch (e) { } catch (e) {
console.error("Queue call error:", e); console.error("Queue call error:", e);
setQueued(false); } finally {
setSending(false);
} }
}; };
@ -112,41 +240,102 @@ export const AutocallStatusCard = ({ order, userRole }) => {
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null; const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
const lastSlot = lastCall ? slotText(lastCall) : null; const lastSlot = lastCall ? slotText(lastCall) : null;
// Единый человеческий статус (по аналогии со статусом SMS)
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called;
return ( return (
<div className="p-4"> <div className="p-4">
<div className="mb-3"> <div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3> <h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
</div> </div>
{/* Итог */} {/* Единый статус — как у SMS */}
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
{optOut ? ( <Badge tone={statusMeta.tone}>{statusMeta.label}</Badge>
<Badge tone="danger">🚫 Отказ от звонков</Badge>
) : isAutocall ? (
<Badge tone="accent">🤖 Согласовано роботом</Badge>
) : calls.length > 0 ? (
<Badge tone={lastMeta?.tone || "neutral"}>{lastMeta?.icon} {lastMeta?.label}</Badge>
) : (
<Badge tone="neutral">Не звонили</Badge>
)}
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>} {lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
</div> </div>
{/* Попытки */} {/* Пояснение исключения */}
{exclusionReason && !optOut && (
<div className="mb-3 rounded-lg bg-[rgba(191,123,33,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
🚫 Робот не звонит на этот номер по настройке: {exclusionReason}
</div>
)}
{/* Счётчик попыток — как у SMS «Попыток отправки: N» */}
{calls.length > 0 && (
<div className="mb-2 text-[11px] text-[var(--color-text-muted)]">
Попыток звонков: {calls.length}
</div>
)}
{/* Состояние ручного звонка: заявка, набор, результат, «когда позвонят» */}
{process && (
<div
className={[
"mb-3 rounded-lg px-2.5 py-1.5 text-[11px]",
process.tone === "danger"
? "bg-[rgba(239,68,68,0.08)] text-[var(--color-danger)]"
: process.tone === "warning"
? "bg-[rgba(245,158,11,0.08)] text-[var(--color-warning)]"
: process.tone === "accent"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
].join(" ")}
>
<div className="flex items-center gap-1.5 font-medium">
{processing && (
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" aria-hidden="true" />
)}
<span>{process.title}</span>
</div>
<div className="mt-0.5 text-[10px] opacity-80">{process.hint}</div>
{process.phase === "done" && callAfter && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
{(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome}
</Badge>
<span className="text-[10px] opacity-80">{fmtTime(callAfter.created_at)}</span>
{slotText(callAfter) && <span className="text-[10px] opacity-80">{slotText(callAfter)}</span>}
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
</div>
)}
</div>
)}
{/* История звонков — как «История SMS» */}
{calls.length > 0 ? ( {calls.length > 0 ? (
<div className="space-y-1.5"> <div className="mt-1 border-t border-[var(--color-border)] pt-3">
{calls.map((c) => { <div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div>
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" }; {loading && <div className="mb-1 text-[10px] text-[var(--color-text-muted)]">обновляем…</div>}
const slot = slotText(c); <div className="space-y-1.5">
return ( {calls.map((c, idx) => {
<div key={c.id} className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px]"> const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span> const slot = slotText(c);
<Badge tone={m.tone}>{m.icon} {m.label}</Badge> const attemptNo = calls.length - idx;
{slot && <span className="text-[var(--color-text)]">{slot}</span>} return (
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>} <div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
</div> <div className="flex flex-wrap items-center gap-2 text-[11px]">
); <span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
})} <Badge tone={m.tone}>{m.icon} {m.label}</Badge>
{c.call_type === "pkp" && <span className="text-[10px] text-[var(--color-text-muted)]">🏪 самовывоз</span>}
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
</div>
{(slot || c.attempt_no > 1 || c.duration_sec > 0) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{c.attempt_no > 1 && <span>Попытка {c.attempt_no}</span>}
{slot && <span>Выбрано: {slot}</span>}
{c.duration_sec > 0 && <span>{Math.round(c.duration_sec)} сек</span>}
{c.scenario === "B" && <span>только завтра</span>}
{c.scenario === "A" && <span>завтра/послезавтра</span>}
{c.changed && <span className="text-[var(--color-warning)]">клиент правил выбор</span>}
</div>
)}
</div>
);
})}
</div>
</div> </div>
) : ( ) : (
<div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div> <div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
@ -161,23 +350,48 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{/* Кнопки */} {/* Кнопки */}
{canManage && ( {canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3"> <div className="mt-3 border-t border-[var(--color-border)] pt-3">
<button {autocallBlocked && !orderAgreed && (
type="button" <div className="mb-2 rounded-lg bg-[rgba(245,158,11,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
onClick={handleCallNow} 🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами.
disabled={queued || optOut} </div>
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50" )}
> {orderAgreed && (
{queued ? "✓ В очереди" : "📞 Позвонить сейчас"} <div className="mb-2 rounded-lg bg-[var(--color-surface-strong)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-muted)]">
</button> Заказ уже согласован — автодозвон не нужен. Если надо переспросить клиента, позвоните сами.
<button </div>
type="button" )}
onClick={handleToggleOptOut} <div className="flex gap-2">
disabled={toggling} <button
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50" type="button"
> onClick={handleCallNow}
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"} disabled={sending || processing || optOut || autocallBlocked || orderAgreed}
</button> title={
orderAgreed
? "Заказ уже согласован — звонок робота не нужен"
: autocallBlocked
? (availability?.reason || "Автодозвон недоступен")
: undefined
}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
>
{sending
? "Отправляем заявку…"
: process?.phase === "dialing"
? "📞 Звоним…"
: processing
? "✓ Заявка в очереди"
: "📞 Позвонить сейчас"}
</button>
<button
type="button"
onClick={handleToggleOptOut}
disabled={toggling}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
>
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
</button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -689,11 +689,21 @@ export const OrderDetailPanel = ({
// ---- Drag-and-drop block reordering state ---- // ---- Drag-and-drop block reordering state ----
const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole); const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole);
const [blockOrder, setBlockOrder] = React.useState(() => { const [blockOrder, setBlockOrder] = React.useState(() => {
let saved = null;
try { try {
const saved = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]"); const parsed = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
if (Array.isArray(saved) && saved.length > 0) return saved; if (Array.isArray(parsed) && parsed.length > 0) saved = parsed;
} catch {} } catch {}
return DEFAULT_BLOCK_ORDER; if (!saved) return DEFAULT_BLOCK_ORDER;
// Мердж с дефолтом: новые блоки (например, autocall_status) добавляются,
// устаревшие ключи удаляются — иначе сохранённый порядок навсегда скрывает новые блоки.
const merged = [
// сначала сохранённые ключи, которые ещё актуальны
...saved.filter((k) => DEFAULT_BLOCK_ORDER.includes(k)),
// затем новые блоки, которых нет в сохранённом — вставляем рядом со «Статус SMS»
...DEFAULT_BLOCK_ORDER.filter((k) => !saved.includes(k)),
];
return merged;
}); });
const [activeTab, setActiveTab] = React.useState(() => { const [activeTab, setActiveTab] = React.useState(() => {
try { try {

View File

@ -2,8 +2,9 @@ import React from "react";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Input } from "../UI/Input"; import { Input } from "../UI/Input";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DatePicker } from "../UI/DatePicker"; import { DateRangePicker } from "../UI/DateRangePicker";
import { Switch } from "../UI/Switch"; import { Switch } from "../UI/Switch";
import { AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
const DropdownSelect = ({ value, options, onChange, placeholder }) => { const DropdownSelect = ({ value, options, onChange, placeholder }) => {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
@ -121,10 +122,17 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
{ value: "pickup", label: "Самовывоз" }, { value: "pickup", label: "Самовывоз" },
]; ];
const smsProblemOptions = [ const smsStatusOptions = [
{ value: "all", label: "Проблемы SMS" }, { value: "all", label: "Статус SMS" },
{ value: "problem_not_sent", label: "SMS не отправлено" }, { value: "not_sent", label: "Не отправлено" },
{ value: "problem_not_delivered", label: "SMS не доставлено" }, { value: "sent", label: "Отправлено" },
{ value: "delivered", label: "Доставлено" },
{ value: "error", label: "Ошибка" },
];
const autocallStatusOptions = [
{ value: "all", label: "Статус автодозвона" },
...AUTOCALL_STATUS_OPTIONS,
]; ];
return ( return (
@ -205,29 +213,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
/> />
</div> </div>
{/* Row 2: Date range + SMS problems (width matches its text) + hide-completed toggle. {/* Row 2: Период + Проблемы SMS + Прозвонить + hide-completed toggle.
Align by the bottom edge of the input fields, not by center — dates have a Выравнивание по верхней линии полей ввода. */}
label above the field, so items-center would lift SMS/toggle above them. */} <div className="flex flex-wrap items-start gap-3">
<div className="flex flex-wrap items-end gap-3"> <DateRangePicker
<DatePicker valueFrom={filters.dateFrom || ""}
value={filters.dateFrom || ""} valueTo={filters.dateTo || ""}
onChange={(v) => updateFilter("dateFrom", v)} onChange={(from, to) => {
placeholder="С даты" setFilters((current) => ({ ...current, dateFrom: from, dateTo: to }));
label="Период: с" }}
className="min-w-[140px] flex-1 md:max-w-[200px]" className="min-w-[180px] flex-1 md:max-w-[240px]"
/>
<DatePicker
value={filters.dateTo || ""}
onChange={(v) => updateFilter("dateTo", v)}
placeholder="По дату"
label="по"
className="min-w-[140px] flex-1 md:max-w-[200px]"
/> />
{hasDateFilter && ( {hasDateFilter && (
<button <button
type="button" type="button"
onClick={clearDateFilter} onClick={clearDateFilter}
className="mb-0.5 h-[46px] gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)]" className="h-[46px] gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)]"
> >
✕ Сбросить ✕ Сбросить
</button> </button>
@ -235,9 +236,16 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
<DropdownSelect <DropdownSelect
value={filters.smsStatus || "all"} value={filters.smsStatus || "all"}
options={smsProblemOptions} options={smsStatusOptions}
onChange={(v) => updateFilter("smsStatus", v)} onChange={(v) => updateFilter("smsStatus", v)}
placeholder="Проблемы SMS" placeholder="Статус SMS"
/>
<DropdownSelect
value={filters.autocallStatus || "all"}
options={autocallStatusOptions}
onChange={(v) => updateFilter("autocallStatus", v)}
placeholder="Статус автодозвона"
/> />
{typeof onToggleHideCompleted === "function" && ( {typeof onToggleHideCompleted === "function" && (

View File

@ -103,6 +103,30 @@ const SMS_STATUS_LABELS = {
pending: "в очереди", pending: "в очереди",
}; };
// ─── Автодозвон: outcome → человеческий статус ─────────────────────
const AUTOCALL_OUTCOME_LABELS = {
confirmed: { label: "Автодозвон: клиент согласовал слот", tone: "ok", icon: "🤖" },
confirmed_silent: { label: "Автодозвон: подтвердил молчанием", tone: "ok", icon: "🤖" },
confirmed_kept_previous:{ label: "Автодозвон: клиент правил выбор", tone: "warning", icon: "🤖" },
no_answer: { label: "Автодозвон: не взял трубку", tone: "danger", icon: "🤖" },
no_input_day: { label: "Автодозвон: не выбрал день", tone: "warning", icon: "🤖" },
no_input_half: { label: "Автодозвон: не выбрал время", tone: "warning", icon: "🤖" },
invalid_number: { label: "Автодозвон: неверный номер", tone: "danger", icon: "🤖" },
error: { label: "Автодозвон: ошибка звонка", tone: "danger", icon: "🤖" },
};
const AUTOCALL_DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
const AUTOCALL_HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
const autocallSlotText = (l) => {
if (!l) return null;
const day = AUTOCALL_DAY_LABEL[l.dtmf_day];
const half = AUTOCALL_HALF_LABEL[l.dtmf_half];
if (day && half) return `${day}, ${half}`;
if (day) return day;
return null;
};
const fmtTimestamp = (ts) => { const fmtTimestamp = (ts) => {
if (!ts) return "—"; if (!ts) return "—";
try { try {
@ -172,7 +196,7 @@ const STAGES_PICKUP = [
{ key: "picked_up", icon: "📦", label: "Вывезено" }, { key: "picked_up", icon: "📦", label: "Вывезено" },
]; ];
const getActiveStageIndex = (order, smsLogs = []) => { const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || ""; const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || ""; const notifStatus = order?.notificationStatus || order?.notification_status || "";
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at; const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
@ -197,6 +221,12 @@ const getActiveStageIndex = (order, smsLogs = []) => {
return 2; return 2;
} }
// Автодозвон: если робот согласовал слот — это уже «Согласовано»
const hasAutocallConfirmed = autocallLogs.some((l) => ["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome));
if (hasAutocallConfirmed) {
return 2;
}
// SMS stage counts as done when any first_sms record exists (even failed), not just when delivered // SMS stage counts as done when any first_sms record exists (even failed), not just when delivered
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms"); const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) { if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
@ -206,10 +236,10 @@ const getActiveStageIndex = (order, smsLogs = []) => {
return 0; return 0;
}; };
const ProgressStepper = ({ order, smsLogs = [] }) => { const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup"; const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY; const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
const activeIdx = getActiveStageIndex(order, smsLogs); const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs);
if (activeIdx === -1) { if (activeIdx === -1) {
return ( return (
@ -276,7 +306,7 @@ const ProgressStepper = ({ order, smsLogs = [] }) => {
}; };
// ─── Current Status Summary (compact card above timeline) ─────────── // ─── Current Status Summary (compact card above timeline) ───────────
const StatusSummary = ({ order, smsLogs = [] }) => { const StatusSummary = ({ order, smsLogs = [], autocallLogs = [] }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup"; const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || ""; const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || ""; const notifStatus = order?.notificationStatus || order?.notification_status || "";
@ -350,6 +380,26 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" }); items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
} }
// Автодозвон — итог: показываем последний УСПЕШНЫЙ звонок (confirmed),
// если его не было — последний звонок любым исходом.
const confirmedAutocall = autocallLogs?.find((l) =>
["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome)
);
const lastAutocall = confirmedAutocall || (autocallLogs && autocallLogs.length > 0 ? autocallLogs[0] : null);
if (lastAutocall) {
const acMeta = AUTOCALL_OUTCOME_LABELS[lastAutocall.outcome] || { label: lastAutocall.outcome || "—", tone: "muted" };
const acSlot = autocallSlotText(lastAutocall);
const acValue = acSlot ? `${acMeta.label} · ${acSlot}` : acMeta.label;
items.push({
icon: "🤖",
label: "Автодозвон",
value: `${acValue}${lastAutocall.was_test_mode ? " · 🧪 тест" : ""}`,
tone: acMeta.tone,
});
} else {
items.push({ icon: "🤖", label: "Автодозвон", value: "не звонили", tone: "muted" });
}
// Driver (delivery only) // Driver (delivery only)
if (!isPickup) { if (!isPickup) {
if (driverName) { if (driverName) {
@ -394,7 +444,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
<span className="text-base flex-shrink-0">{item.icon}</span> <span className="text-base flex-shrink-0">{item.icon}</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-[var(--color-text-muted)] leading-tight">{item.label}</p> <p className="text-[11px] font-medium text-[var(--color-text-muted)] leading-tight">{item.label}</p>
<p className={`text-sm font-semibold truncate ${textStyles[item.tone] || textStyles.muted}`}> <p className={`text-sm font-semibold break-words ${textStyles[item.tone] || textStyles.muted}`}>
{item.value} {item.value}
</p> </p>
</div> </div>
@ -408,6 +458,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
export const OrderHistoryTimeline = ({ order, userRole }) => { export const OrderHistoryTimeline = ({ order, userRole }) => {
const [history, setHistory] = useState(null); const [history, setHistory] = useState(null);
const [smsLogs, setSmsLogs] = useState([]); const [smsLogs, setSmsLogs] = useState([]);
const [autocallLogs, setAutocallLogs] = useState([]);
const [pushLogs, setPushLogs] = useState([]); const [pushLogs, setPushLogs] = useState([]);
const [pushSubscriptions, setPushSubscriptions] = useState([]); const [pushSubscriptions, setPushSubscriptions] = useState([]);
const [cityProfile, setCityProfile] = useState(null); const [cityProfile, setCityProfile] = useState(null);
@ -440,6 +491,18 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
if (!smsError) setSmsLogs(smsData || []); if (!smsError) setSmsLogs(smsData || []);
} catch { if (!cancelled) setSmsLogs([]); } } catch { if (!cancelled) setSmsLogs([]); }
// Автодозвон — лог робо-звонков
try {
const { data: acData, error: acError } = await supabase
.from("autocall_log")
.select("id, outcome, attempt_no, scenario, dtmf_day, dtmf_half, changed, was_test_mode, sip_code, duration_sec, created_at")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(50);
if (cancelled) return;
if (!acError) setAutocallLogs(acData || []);
} catch { if (!cancelled) setAutocallLogs([]); }
// Push is stored on the client phone, so it is available for every current and future order. // Push is stored on the client phone, so it is available for every current and future order.
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7"); const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
if (phoneNormalized) { if (phoneNormalized) {
@ -561,6 +624,26 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
} }
} }
if (autocallLogs && autocallLogs.length > 0) {
for (const ac of autocallLogs) {
const meta = AUTOCALL_OUTCOME_LABELS[ac.outcome] || { label: `Автодозвон: ${ac.outcome || "—"}`, tone: "muted", icon: "🤖" };
const slot = autocallSlotText(ac);
events.push({
id: `autocall-${ac.id}`,
created_at: ac.created_at,
type: "autocall",
label: meta.label,
tone: meta.tone,
slot,
attemptNo: ac.attempt_no,
wasTest: ac.was_test_mode,
changed: ac.changed,
sipCode: ac.sip_code,
durationSec: ac.duration_sec,
});
}
}
if (pushLogs && pushLogs.length > 0) { if (pushLogs && pushLogs.length > 0) {
for (const push of pushLogs) { for (const push of pushLogs) {
events.push({ events.push({
@ -609,7 +692,7 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return events; return events;
}, [history, smsLogs, pushLogs, invitation, cityProfile]); }, [history, smsLogs, autocallLogs, pushLogs, invitation, cityProfile]);
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active); const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
const latestPush = pushLogs[0] || null; const latestPush = pushLogs[0] || null;
@ -618,8 +701,8 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ProgressStepper order={order} smsLogs={smsLogs} /> <ProgressStepper order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
<StatusSummary order={order} smsLogs={smsLogs} /> <StatusSummary order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
<div className={`rounded-xl border px-3 py-2.5 ${ <div className={`rounded-xl border px-3 py-2.5 ${
activePushSubscriptions.length > 0 activePushSubscriptions.length > 0
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]" ? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
@ -693,6 +776,34 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
</p> </p>
)} )}
{ev.type === "autocall" && (
<>
<p className="mt-0.5 text-sm">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
ev.tone === "ok"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: ev.tone === "danger"
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
: ev.tone === "warning"
? "bg-[rgba(245,158,11,0.12)] text-[var(--color-warning)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
}`}>
{ev.tone === "ok" ? "успешно" : ev.tone === "danger" ? "неуспешно" : ev.tone === "warning" ? "требует внимания" : "—"}
</span>
</p>
{ev.slot && <p className="mt-0.5 text-xs text-[var(--color-text)]">Слот: {ev.slot}</p>}
{ev.changed && (
<p className="mt-0.5 text-xs text-[var(--color-warning)]">⚠ Клиент правил выбор в звонке</p>
)}
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
Попытка {ev.attemptNo || "—"}
{ev.durationSec ? ` · длительность ${ev.durationSec}с` : ""}
{ev.sipCode ? ` · SIP ${ev.sipCode}` : ""}
{ev.wasTest ? " · 🧪 тест" : ""}
</p>
</>
)}
{ev.type === "reason" && ( {ev.type === "reason" && (
<p className={`mt-0.5 text-xs ${ <p className={`mt-0.5 text-xs ${
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]" ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"

View File

@ -7,6 +7,7 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
import { getSmsFailureReason, isSmsFundsError } from "../../services/orderGroupViews";
// ── Status labels ──────────────────────────────────────────────────────────── // ── Status labels ────────────────────────────────────────────────────────────
const NOTIF_LABELS = { const NOTIF_LABELS = {
@ -209,6 +210,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
const hasSecondSms = isDelivered(secondSmsLog); const hasSecondSms = isDelivered(secondSmsLog);
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status); const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status); const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
// Причина неудачи словами: в логе лежит только код сервиса (201, 202, ...).
const failureReason = (log) => {
if (!log) return null;
if (log.status === "expired") return "срок доставки сообщения истёк";
return getSmsFailureReason(log) || log.status;
};
const fundsProblem = [firstSmsLog, secondSmsLog].some((log) => isSmsFundsError(log));
return ( return (
<div className="p-4"> <div className="p-4">
@ -226,11 +234,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
{hasFirstSms ? ( {hasFirstSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div> <div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
) : firstSmsFailed ? ( ) : firstSmsFailed ? (
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}</div> <div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ не отправлено: {failureReason(firstSmsLog)}</div>
) : firstSmsAt ? ( ) : firstSmsAt ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div> <div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
) : notifStatus === "link_ready" ? ( ) : notifStatus === "link_ready" ? (
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div> <div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
) : notifStatus === "manual_required" && smsLog.length === 0 ? (
<div className="text-[var(--color-text-muted)]">SMS не отправлялись — согласование шло без SMS</div>
) : ( ) : (
<div className="text-[var(--color-text-muted)]">не отправлено</div> <div className="text-[var(--color-text-muted)]">не отправлено</div>
)} )}
@ -245,7 +255,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
{hasSecondSms ? ( {hasSecondSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div> <div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
) : secondSmsFailed ? ( ) : secondSmsFailed ? (
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}</div> <div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ не отправлено: {failureReason(secondSmsLog)}</div>
) : notifStatus === "first_sms_sent" && countdown ? ( ) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]"> <div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span> отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
@ -267,7 +277,24 @@ export const SmsStatusCard = ({ order, userRole }) => {
{/* Error */} {/* Error */}
{lastError && ( {lastError && (
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]"> <div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
⚠ {lastError} ⚠ {typeof lastError === "string" ? getSmsFailureReason(lastError) || lastError : lastError}
</div>
)}
{/* Ручное управление без ошибок в логе: объясняем, что это значит */}
{notifStatus === "manual_required" && !lastError && !fundsProblem && (
<div className="rounded-lg bg-[rgba(245,158,11,0.10)] px-2 py-1.5 text-[var(--color-text)]">
⚠ Клиент не согласовал доставку — SMS-уведомления по расписанию закончились. Никакой ошибки
отправки не было: сообщение клиенту не уходило, потому что оно и не требовалось.
{smsLog.length === 0 && " Записей об отправке SMS нет."} Дальше — вручную: позвоните клиенту
или отправьте SMS вручную.
</div>
)}
{/* Нехватка средств — самая частая причина, объясняем прямо в карточке */}
{fundsProblem && (
<div className="rounded-lg border border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
⛔ На счёте SMS-сервиса закончились средства — сообщение не ушло. Пополните счёт: отправка возобновится автоматически.
</div> </div>
)} )}

View File

@ -52,7 +52,7 @@ export const useOrderGroups = ({
} }
setLoadError(""); setLoadError("");
if (!enabled) { setOrderGroups([]); setIsLoading(false); return; } if (!enabled) { setIsLoading(false); return; }
// Single-group mode: load only the requested group (avoids pulling all // Single-group mode: load only the requested group (avoids pulling all
// 430 groups with their full source_orders JSON — 2.6MB — for a detail view). // 430 groups with their full source_orders JSON — 2.6MB — for a detail view).
@ -80,12 +80,17 @@ export const useOrderGroups = ({
if (groupsResult.error) { if (groupsResult.error) {
setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки"); setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки");
setOrderGroups([]); // Список не стираем — показываем прежние данные, а не пустой экран.
setIsLoading(false); setIsLoading(false);
return; return;
} }
const payload = groupsResult.data || {}; const payload = groupsResult.data || {};
// Неожиданная форма ответа — тоже не стираем список.
if (!Array.isArray(payload.data)) {
setIsLoading(false);
return;
}
setOrderGroups(payload.data || []); setOrderGroups(payload.data || []);
setTotalGroups(payload.total ?? (payload.data?.length || 0)); setTotalGroups(payload.total ?? (payload.data?.length || 0));
setIsLoading(false); setIsLoading(false);
@ -110,7 +115,7 @@ export const useOrderGroups = ({
} }
if (cancelled) return; if (cancelled) return;
}; };
if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); } if (enabled) doLoad(); else { setIsLoading(false); }
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [loadLiveData, enabled]); }, [loadLiveData, enabled]);

View File

@ -14,6 +14,7 @@ import { Skeleton } from "../components/UI/Loading";
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting"; import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
import { import {
confirmDeliveryChoice, confirmDeliveryChoice,
fetchBusinessSchedule,
fetchDeliveryInvitation, fetchDeliveryInvitation,
} from "../services/deliveryInvitationApi"; } from "../services/deliveryInvitationApi";
@ -127,30 +128,52 @@ const isSundayKey = (dateKey) => {
return d.getUTCDay() === 0; return d.getUTCDay() === 0;
}; };
const getNextWorkdayKey = (dateKey) => { // Рабочие дни доставки из «Графика работы» (1=пн … 7=вс).
// Дефолт — прежнее поведение страницы: все дни, кроме воскресенья.
const DEFAULT_DELIVERY_DAYS = [1, 2, 3, 4, 5, 6];
const isAllowedDeliveryDayKey = (dateKey, workDays = DEFAULT_DELIVERY_DAYS) => {
if (!dateKey) return false;
const d = new Date(`${dateKey}T12:00:00Z`);
if (Number.isNaN(d.getTime())) return false;
const weekday = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
return workDays.includes(weekday);
};
const getNextWorkdayKey = (dateKey, workDays = DEFAULT_DELIVERY_DAYS) => {
let next = addDaysToDateKey(dateKey, 1); let next = addDaysToDateKey(dateKey, 1);
while (isSundayKey(next)) { let guard = 0;
while (!isAllowedDeliveryDayKey(next, workDays) && guard < 14) {
next = addDaysToDateKey(next, 1); next = addDaysToDateKey(next, 1);
guard += 1;
} }
return next; return next;
}; };
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => { const getAllowedDeliveryDateKeys = (referenceDate = new Date(), workDays = DEFAULT_DELIVERY_DAYS) => {
const todayKey = getBusinessTodayKey(referenceDate); const todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = getNextWorkdayKey(todayKey); const firstWorkday = getNextWorkdayKey(todayKey, workDays);
const secondWorkday = getNextWorkdayKey(firstWorkday); const secondWorkday = getNextWorkdayKey(firstWorkday, workDays);
return new Set([firstWorkday, secondWorkday].filter(Boolean)); return new Set([firstWorkday, secondWorkday].filter(Boolean));
}; };
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => { const isAllowedDeliverySlotDate = (
dateKey,
referenceDate = new Date(),
workDays = DEFAULT_DELIVERY_DAYS,
) => {
if (!dateKey) { if (!dateKey) {
return false; return false;
} }
return getAllowedDeliveryDateKeys(referenceDate).has(dateKey); return getAllowedDeliveryDateKeys(referenceDate, workDays).has(dateKey);
}; };
export const groupSlotsFromInvitation = (invitation, referenceDate = new Date()) => { export const groupSlotsFromInvitation = (
invitation,
referenceDate = new Date(),
workDays = DEFAULT_DELIVERY_DAYS,
) => {
if (!invitation) { if (!invitation) {
return []; return [];
} }
@ -160,10 +183,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
const deliveryTime = invitation.deliveryTime; const deliveryTime = invitation.deliveryTime;
if (!rawSlots.length && !deliveryDate) { if (!rawSlots.length && !deliveryDate) {
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday) // Fallback: generate default delivery slots (next 2 workdays, both halves)
const todayKey = getBusinessTodayKey(referenceDate); const todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = getNextWorkdayKey(todayKey); const firstWorkday = getNextWorkdayKey(todayKey, workDays);
const secondWorkday = getNextWorkdayKey(firstWorkday); const secondWorkday = getNextWorkdayKey(firstWorkday, workDays);
return [ return [
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" }, { id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" }, { id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
@ -193,7 +216,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|| deliveryDate || deliveryDate
|| ""; || "";
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) { if (!isAllowedDeliverySlotDate(parsedDate, referenceDate, workDays)) {
return null; return null;
} }
@ -217,7 +240,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
return null; return null;
} }
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) { if (!isAllowedDeliverySlotDate(slotDate, referenceDate, workDays)) {
return null; return null;
} }
@ -305,6 +328,28 @@ export const ClientDeliveryPage = () => {
[token, invitation?.smsSentAt], [token, invitation?.smsSentAt],
); );
// Рабочие дни и часы — из графика компании, те же, что использует робот.
const [pickupWorkDays, setPickupWorkDays] = React.useState([1, 2, 3, 4, 5]);
const [deliveryWorkDays, setDeliveryWorkDays] = React.useState(DEFAULT_DELIVERY_DAYS);
const [pickupSchedule, setPickupSchedule] = React.useState({
halfEnd: "13:00",
sameDayCutoff: "15:30",
});
React.useEffect(() => {
let cancelled = false;
fetchBusinessSchedule()
.then((s) => {
if (cancelled) return;
setPickupWorkDays(s.pickupDays);
setDeliveryWorkDays(s.deliveryDays);
setPickupSchedule({ halfEnd: s.pickupHalfEnd, sameDayCutoff: s.pickupSameDayCutoff });
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@ -350,7 +395,7 @@ export const ClientDeliveryPage = () => {
}; };
}, [token]); }, [token]);
const slots = groupSlotsFromInvitation(invitation, referenceDate); const slots = groupSlotsFromInvitation(invitation, referenceDate, deliveryWorkDays);
const invitationState = invitation?.state || "awaiting_choice"; const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
@ -397,7 +442,7 @@ export const ClientDeliveryPage = () => {
setSelectedSlot( setSelectedSlot(
buildSelectedSlotFromInvitation( buildSelectedSlotFromInvitation(
loadedInvitation, loadedInvitation,
groupSlotsFromInvitation(loadedInvitation, referenceDate), groupSlotsFromInvitation(loadedInvitation, referenceDate, deliveryWorkDays),
) || effectiveSelectedSlot, ) || effectiveSelectedSlot,
); );
setChoiceSaved(true); setChoiceSaved(true);
@ -642,6 +687,8 @@ export const ClientDeliveryPage = () => {
onSelectSlot={handleSlotSelect} onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId} selectedSlotId={selectedSlotId}
referenceDate={referenceDate} referenceDate={referenceDate}
pickupDays={pickupWorkDays}
schedule={pickupSchedule}
/> />
) : null} ) : null}

View File

@ -44,6 +44,7 @@ describe("ClientDeliveryPage helpers", () => {
searchDate: "2026-04-14", searchDate: "2026-04-14",
}), }),
).toEqual({ ).toEqual({
deliveryType: "delivery",
deliveryDate: "2026-04-16", deliveryDate: "2026-04-16",
deliveryTime: "После обеда", deliveryTime: "После обеда",
}); });

View File

@ -0,0 +1,123 @@
/**
* @file autocallAvailability.js
* @description Можно ли роботу звонить по заказу прямо сейчас.
*
* Планировщик (scripts/autocall_scheduler.py) перед созданием call-файла
* вычисляет окно звонка: даты, которые робот может предложить клиенту.
* Если окно пусто — звонить не о чем, и кнопку автодозвона в карточке
* нужно блокировать, а не «вечно держать заявку в очереди».
*
* Функции здесь повторяют логику планировщика 1:1 (pickup_window /
* delivery_window) и читают те же настройки из БД (business_schedule_settings),
* так что интерфейс и планировщик всегда согласны друг с другом.
*
* Ключевой принцип (Михаил): заявку «позвонить сейчас» НЕ закрываем никогда.
* Просто не даём выбрать автодозвон, когда звонить не о чем.
*/
import { supabase } from "../supabaseClient";
/** Строка настроек графика: кэш на 5 минут — в рамках сессии диспетчера. */
let scheduleCache = null;
let scheduleCacheAt = 0;
const SCHEDULE_TTL_MS = 5 * 60 * 1000;
const loadSchedule = async () => {
const now = Date.now();
if (scheduleCache && now - scheduleCacheAt < SCHEDULE_TTL_MS) return scheduleCache;
try {
const { data, error } = await supabase
.from("business_schedule_settings")
.select("pickup_days, delivery_days, pickup_same_day_cutoff")
.eq("id", 1)
.maybeSingle();
if (error) throw error;
scheduleCache = {
pickupDays: (data?.pickup_days || [1, 2, 3, 4, 5]).map((d) => parseInt(d, 10)),
deliveryDays: (data?.delivery_days || [1, 2, 3, 4, 5]).map((d) => parseInt(d, 10)),
// «15:30:00» → 930 минут от полуночи
pickupCutoffMinutes: (() => {
const raw = data?.pickup_same_day_cutoff;
if (!raw) return null;
const parts = String(raw).split(":").map((p) => parseInt(p, 10));
if (parts.length < 2 || parts.some(Number.isNaN)) return null;
return parts[0] * 60 + parts[1];
})(),
};
} catch {
scheduleCache = {
pickupDays: [1, 2, 3, 4, 5],
deliveryDays: [1, 2, 3, 4, 5],
pickupCutoffMinutes: null,
};
}
scheduleCacheAt = now;
return scheduleCache;
};
/** Ближайшие `count` рабочих дней начиная с `start` включительно (1=пн…7=вс). */
export const workDayWindow = (start, workDays, count) => {
const days = [];
const day = new Date(start);
day.setHours(12, 0, 0, 0); // полдень локальный — без эффектов перехода через полночь
let guard = 0;
while (days.length < count && guard < 30) {
if (workDays.includes(day.getDay() === 0 ? 7 : day.getDay())) days.push(new Date(day));
day.setDate(day.getDate() + 1);
guard += 1;
}
return days;
};
/** Окно самовывоза — повторяет pickup_window планировщика. */
export const pickupWindow = (readyAt, schedule, now = new Date()) => {
const workDays = schedule?.pickupDays || [1, 2, 3, 4, 5];
const cutoff = schedule?.pickupCutoffMinutes;
const ready = readyAt ? new Date(readyAt) : new Date();
ready.setHours(0, 0, 0, 0);
const window = workDayWindow(ready, workDays, 3);
const nowMsk = new Date(now);
const todayStart = new Date(nowMsk);
todayStart.setHours(0, 0, 0, 0);
let firstDay = todayStart;
if (cutoff != null) {
const nowMin = nowMsk.getHours() * 60 + nowMsk.getMinutes();
if (nowMin >= cutoff) firstDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
}
return window.filter((d) => d >= firstDay);
};
/**
* Есть ли у робота хоть один день, который он может предложить клиенту.
* `order` — объект группы (delivery_type, created_at / createdAt, sms_sent_at).
*/
export const isAutocallPossible = async (order) => {
if (!order) return { possible: false, reason: "Нет данных заказа" };
const schedule = await loadSchedule();
const callType = (order.delivery_type || order.deliveryType || "").toString().toLowerCase() === "pickup" ? "pkp" : "dlv";
const createdRaw = order.created_at || order.createdAt || order.sms_sent_at || order.smsSentAt;
const readyRaw = createdRaw ? new Date(createdRaw) : new Date();
if (callType === "pkp") {
const available = pickupWindow(readyRaw, schedule);
return available.length > 0
? { possible: true, availableDays: available.map(fmtDay) }
: { possible: false, reason: "Окно самовывоза закрыто: заказ готов раньше, чем «день готовности + два рабочих дня»" };
}
// Доставка: два рабочих дня после готовности, не в день готовности.
const start = new Date(Math.max(todayStart().getTime(), todayStart(readyRaw).getTime() + 24 * 60 * 60 * 1000));
const window = workDayWindow(start, schedule.deliveryDays, 2);
return window.length > 0
? { possible: true, availableDays: window.map(fmtDay) }
: { possible: false, reason: "Нет рабочих дней доставки по графику" };
};
const todayStart = (d = new Date()) => {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
};
const fmtDay = (d) =>
d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", weekday: "short" });

View File

@ -0,0 +1,203 @@
/**
* @file autocallProcess.js
* @description Что происходит с ручным звонком из карточки заказа.
*
* Логист нажимает «Позвонить сейчас»: заявка уходит в очередь, робот берёт её
* на очередном тике (раз в минуту, только в рабочее время) и звонит. Раньше
* в интерфейсе не было видно ни очереди, ни результата — кнопка «отжималась»
* и всё. Здесь собраны состояния процесса в человеческие фразы.
*
* Время считаем в Москве (браузер может быть в другом поясе), часы и дни
* берём из настроек кампании автодозвона.
*/
const WEEKDAY_LABEL = {
1: "в понедельник",
2: "во вторник",
3: "в среду",
4: "в четверг",
5: "в пятницу",
6: "в субботу",
7: "в воскресенье",
};
/** Строка настроек «1,2,3,4,5,6» → [1,2,3,4,5,6]. */
export const parseWorkDays = (raw, fallback = [1, 2, 3, 4, 5, 6]) => {
if (Array.isArray(raw)) {
const days = raw.map((d) => Number(d)).filter((d) => d >= 1 && d <= 7);
return days.length ? days : fallback;
}
const text = String(raw ?? "");
const days = text.split(/[^0-9]+/).filter(Boolean).map(Number).filter((d) => d >= 1 && d <= 7);
return days.length ? days : fallback;
};
/** Текущее время в Москве: минуты от полуночи и день недели (1=пн). */
export const moscowNow = (now = new Date()) => {
const parts = new Intl.DateTimeFormat("ru-RU", {
timeZone: "Europe/Moscow",
hour: "2-digit",
minute: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(now);
const get = (type) => (parts.find((p) => p.type === type) || {}).value || "";
const weekdays = { пн: 1, вт: 2, ср: 3, чт: 4, пт: 5, сб: 6, вс: 7 };
const hour = Number(get("hour")) % 24;
const minute = Number(get("minute"));
return {
minutes: hour * 60 + minute,
hour,
minute,
weekday: weekdays[get("weekday")] || 0,
};
};
const padHour = (h) => `${String(h).padStart(2, "0")}:00`;
/** Сейчас робот звонит? (рабочий день и рабочие часы кампании) */
export const isCallTimeNow = (settings = {}, now = new Date()) => {
const workDays = parseWorkDays(settings.work_days);
const start = Number(settings.work_hours_start ?? 9) * 60;
const end = Number(settings.work_hours_end ?? 20) * 60;
const t = moscowNow(now);
return workDays.includes(t.weekday) && t.minutes >= start && t.minutes < end;
};
/** Когда откроется ближайшее окно звонков: «сегодня», «завтра», «в понедельник». */
export const nextCallDayLabel = (settings = {}, now = new Date()) => {
const workDays = parseWorkDays(settings.work_days);
const start = Number(settings.work_hours_start ?? 9) * 60;
const t = moscowNow(now);
for (let shift = 0; shift <= 7; shift += 1) {
const weekday = ((t.weekday - 1 + shift) % 7) + 1;
if (!workDays.includes(weekday)) continue;
// сегодня окно уже открыто — значит «сейчас», сюда не попадём
if (shift === 0 && t.minutes >= start) continue;
if (shift === 0) return "сегодня";
if (shift === 1) return "завтра";
return WEEKDAY_LABEL[weekday] || "скоро";
}
return "скоро";
};
const fmtTime = (ts) => {
if (!ts) return "—";
try {
// Время показываем по Москве: логисты и админы работают в этом поясе,
// а браузер может быть в другом (частая путаница «звонил в 9 или в 11?»).
return new Date(ts).toLocaleString("ru-RU", {
timeZone: "Europe/Moscow", hour: "2-digit", minute: "2-digit",
});
} catch {
return "—";
}
};
/**
* Состояние ручного звонка в человеческих словах.
*
* @param {{queueItem?: object|null, callAfter?: object|null, settings?: object, now?: Date}} input
* queueItem — заявка из autocall_queue (последняя по заказу)
* callAfter — запись autocall_log, появившаяся после заявки (значит, звонок уже состоялся)
* @returns {null|{phase: string, title: string, hint: string, tone: string}} null — заявок не было
*/
export const describeCallProcess = ({ queueItem, callAfter, settings = {}, now = new Date() } = {}) => {
if (!queueItem) return null;
const workDays = parseWorkDays(settings.work_days);
const startHour = Number(settings.work_hours_start ?? 9);
const endHour = Number(settings.work_hours_end ?? 20);
const requested = `заявка от ${fmtTime(queueItem.requested_at)}`;
if (callAfter) {
return {
phase: "done",
tone: callAfter.was_test_mode ? "warning" : "neutral",
title: callAfter.was_test_mode ? "Звонок сделан в тестовом режиме" : "Звонок сделан",
hint: callAfter.was_test_mode
? `Дозвон шёл на тестовый номер (режим кампании), не клиенту — ${requested}`
: `Результат ниже в истории звонков — ${requested}`,
};
}
if (queueItem.processed_at) {
return {
phase: "dialing",
tone: "accent",
title: "Звоним клиенту…",
hint: `Робот уже набирает номер, результат появится через несколько секунд — ${requested}`,
};
}
// Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам):
// звонок не нужен и планировщик заявку не обрабатывает — говорим честно.
// Заявку при этом НЕ закрываем и НЕ удаляем (правило владельца).
if (settings.order_agreed) {
return {
phase: "pending",
tone: "neutral",
title: "Звонок не потребуется — заказ уже согласован",
hint: `Дата по заказу уже выбрана, заявка так и останется невыполненной — ${requested}`,
};
}
const soon = isCallTimeNow({ work_hours_start: startHour, work_hours_end: endHour, work_days: workDays }, now);
const ageMinutes = (now.getTime() - new Date(queueItem.requested_at).getTime()) / 60000;
// Заявку взяли в работу, но call-файл не создан: по графику окна звонка для
// этого заказа уже нет (например, окно самовывоза закрылось — заказ готов
// раньше, чем «сегодня + 2 рабочих дня»). Планировщик молча пропускает её
// каждый тик — покажем это честно, а не «в очереди».
if (settings.call_unreachable) {
return {
phase: "pending",
tone: "warning",
title: "Заявка в очереди — но роботу пока нечего предложить",
hint: `По графику у заказа нет доступных дней: заявка ждёт открытия окна звонка — ${requested}. Дальше вручную: выберите день в карточке или позвоните сами.`,
};
}
if (soon) {
// В рабочее время заявку обязаны взять на ближайшем тике (раз в минуту).
if (ageMinutes > 15) {
return {
phase: "stuck",
tone: "danger",
title: "Заявку не забрали больше 15 минут",
hint: `Рабочее время идёт, а звонка нет — похоже, планировщик звонков не работает. ${requested}`,
};
}
return {
phase: "pending",
tone: "accent",
title: "В очереди: звонок начнётся в течение минуты",
hint: `Робот забирает заявки раз в минуту (${padHour(startHour)}–${padHour(endHour)}) — ${requested}`,
};
}
return {
phase: "pending_later",
tone: "warning",
title: `Сейчас нерабочее время — позвоним ${nextCallDayLabel(
{ work_hours_start: startHour, work_days: workDays }, now,
)} с ${padHour(startHour)}`,
hint: `Робот звонит только в рабочее время: ${padHour(startHour)}–${padHour(endHour)}, дни — ${workDays.join(", ")} — ${requested}`,
};
};
/**
* Согласован ли уже заказ (дату выбрал клиент или поставил менеджер).
*
* Нужно карточке, чтобы не предлагать автодозвон по заказу, где всё решено.
* ВАЖНО: `manual_required` («нужен менеджер») — это НЕ согласованный заказ:
* дату клиент как раз не выбрал, и звонок робота по нему допустим.
* Отдельно стоит флаг «не звонить» (`autocall_opt_out`) — он про запрет звонков,
* а не про согласование, и обрабатывается своим состоянием.
*/
export const isOrderAgreed = (order) => {
if (!order) return false;
const date = order.delivery_date ?? order.deliveryDate;
const status = String(order.notification_status ?? order.notificationStatus ?? "");
return Boolean(date) || status === "confirmed";
};

View File

@ -0,0 +1,192 @@
/**
* @file autocallProcess.test.js
* @description Ручной звонок из карточки: состояния должны быть понятны логисту —
* в очереди, идёт звонок, звонок сделан, и когда именно позвонят вне рабочего времени.
*/
import { describe, it, expect } from "vitest";
import {
describeCallProcess,
isOrderAgreed,
isCallTimeNow,
moscowNow,
nextCallDayLabel,
parseWorkDays,
} from "./autocallProcess";
/** Время в Москве → Date (браузер может быть в любом поясе, проверяем через МСК). */
const msk = (iso) => new Date(`${iso}+03:00`);
const SETTINGS = { work_hours_start: 9, work_hours_end: 20, work_days: "1,2,3,4,5,6" };
describe("parseWorkDays", () => {
it("читает строку из настроек", () => {
expect(parseWorkDays("1,2,3,4,5")).toEqual([1, 2, 3, 4, 5]);
expect(parseWorkDays([1, 6])).toEqual([1, 6]);
});
it("пусто или мусор → пн–сб по умолчанию", () => {
expect(parseWorkDays("")).toEqual([1, 2, 3, 4, 5, 6]);
expect(parseWorkDays("0,9")).toEqual([1, 2, 3, 4, 5, 6]);
});
});
describe("время Москвы и рабочее окно", () => {
it("считает время в Москве, а не в поясе браузера", () => {
expect(moscowNow(new Date("2026-09-15T07:30:00Z")).minutes).toBe(10 * 60 + 30);
expect(moscowNow(new Date("2026-09-15T07:30:00Z")).weekday).toBe(2); // вторник
});
it("в рабочее время звонить можно, в 21:00 и в воскресенье — нет", () => {
expect(isCallTimeNow(SETTINGS, msk("2026-09-15T12:00:00"))).toBe(true);
expect(isCallTimeNow(SETTINGS, msk("2026-09-15T21:00:00"))).toBe(false);
expect(isCallTimeNow(SETTINGS, msk("2026-09-20T12:00:00"))).toBe(false); // воскресенье
});
});
describe("когда откроется ближайшее окно", () => {
it("вечером — завтра", () => {
expect(nextCallDayLabel(SETTINGS, msk("2026-09-15T21:30:00"))).toBe("завтра");
});
it("в воскресенье — завтра (понедельник)", () => {
expect(nextCallDayLabel(SETTINGS, msk("2026-09-20T12:00:00"))).toBe("завтра");
});
it("в пятницу вечером, если суббота нерабочая — в понедельник", () => {
const weekdaysOnly = { ...SETTINGS, work_days: "1,2,3,4,5" };
expect(nextCallDayLabel(weekdaysOnly, msk("2026-09-18T21:00:00"))).toBe("в понедельник");
});
});
describe("describeCallProcess — что видит логист", () => {
const queueItem = { id: 42, requested_at: "2026-09-15T07:34:00Z", processed_at: null };
it("заявок не было — блока нет", () => {
expect(describeCallProcess({ queueItem: null })).toBeNull();
});
it("в рабочее время: говорит, что звонок будет в течение минуты", () => {
const res = describeCallProcess({
queueItem: { id: 42, requested_at: "2026-09-15T08:57:00Z", processed_at: null }, // 11:57 МСК
settings: SETTINGS,
now: msk("2026-09-15T12:00:00"),
});
expect(res.phase).toBe("pending");
expect(res.title).toContain("в течение минуты");
expect(res.hint).toContain("заявка от 11:57");
});
it("вне рабочего времени: честно говорит, когда позвонят", () => {
const res = describeCallProcess({
queueItem,
settings: SETTINGS,
now: msk("2026-09-15T22:00:00"),
});
expect(res.phase).toBe("pending_later");
expect(res.title).toContain("позвоним завтра с 09:00");
});
it("заявка взята роботом — «звоним клиенту»", () => {
const res = describeCallProcess({
queueItem: { ...queueItem, processed_at: "2026-09-15T07:35:10Z" },
settings: SETTINGS,
now: msk("2026-09-15T10:35:20"),
});
expect(res.phase).toBe("dialing");
expect(res.title).toContain("Звоним клиенту");
});
it("в рабочее время заявку держат больше 15 минут — тревога, а не «в очереди»", () => {
const res = describeCallProcess({
queueItem: { id: 43, requested_at: "2026-09-15T07:00:00Z", processed_at: null },
settings: SETTINGS,
now: msk("2026-09-15T12:00:00"),
});
expect(res.phase).toBe("stuck");
expect(res.tone).toBe("danger");
expect(res.hint).toContain("планировщик звонков не работает");
});
it("та же старая заявка ночью — это не тревога, ждём рабочее время", () => {
const res = describeCallProcess({
queueItem: { id: 43, requested_at: "2026-09-15T07:00:00Z", processed_at: null },
settings: SETTINGS,
now: msk("2026-09-15T23:00:00"),
});
expect(res.phase).toBe("pending_later");
});
it("звонок состоялся — отправляет к результату в истории", () => {
const res = describeCallProcess({
queueItem: { ...queueItem, processed_at: "2026-09-15T07:35:10Z" },
callAfter: { id: 7, outcome: "no_answer", created_at: "2026-09-15T07:36:00Z", was_test_mode: false },
settings: SETTINGS,
now: msk("2026-09-15T10:40:00"),
});
expect(res.phase).toBe("done");
expect(res.title).toBe("Звонок сделан");
expect(res.hint).not.toContain("тестовый номер");
});
it("тестовый режим: предупреждает, что звонили не клиенту", () => {
const res = describeCallProcess({
queueItem,
callAfter: { id: 8, outcome: "no_answer", was_test_mode: true },
settings: SETTINGS,
now: msk("2026-09-15T10:40:00"),
});
expect(res.tone).toBe("warning");
expect(res.hint).toContain("тестовый номер");
});
it("заказ уже согласован — заявку не обрабатываем и говорим об этом честно", () => {
const res = describeCallProcess({
queueItem,
callAfter: null,
settings: { ...SETTINGS, order_agreed: true },
now: msk("2026-09-15T10:40:00"),
});
expect(res.phase).toBe("pending");
expect(res.title).toContain("уже согласован");
expect(res.hint).toContain("останется невыполненной");
});
it("согласованный заказ: без флага order_agreed состояние обычное", () => {
const res = describeCallProcess({
queueItem,
callAfter: null,
settings: SETTINGS,
now: msk("2026-09-15T10:40:00"),
});
expect(res.title).not.toContain("уже согласован");
});
});
describe("isOrderAgreed — когда автодозвон предлагать нельзя", () => {
it("дата выбрана — согласован", () => {
expect(isOrderAgreed({ delivery_date: "2026-09-18", notification_status: "pickup" })).toBe(true);
});
it("статус «согласовано» — согласован", () => {
expect(isOrderAgreed({ notification_status: "confirmed" })).toBe(true);
expect(isOrderAgreed({ notificationStatus: "confirmed" })).toBe(true);
});
it("«ручное управление» — НЕ согласован: клиент дату не выбрал", () => {
expect(isOrderAgreed({ notification_status: "manual_required", delivery_date: null })).toBe(false);
});
it("«первое приглашение отправлено» — НЕ согласован", () => {
expect(isOrderAgreed({ notification_status: "first_sms_sent" })).toBe(false);
});
it("«не звонить» само по себе не делает заказ согласованным", () => {
expect(isOrderAgreed({ notification_status: "first_sms_sent", autocall_opt_out: true })).toBe(false);
});
it("пустой заказ — не согласован", () => {
expect(isOrderAgreed(null)).toBe(false);
expect(isOrderAgreed(undefined)).toBe(false);
expect(isOrderAgreed({})).toBe(false);
});
});

View File

@ -183,6 +183,61 @@ export const __resetLocalDeliveryInvitationCache = () => {
localDeliveryInvitationCache.clear(); localDeliveryInvitationCache.clear();
}; };
/**
* Рабочие дни из графика компании («График работы» в админке).
* Админка — единственный источник правды: и страница клиента, и робот-автодозвон
* обязаны предлагать одни и те же дни, иначе клиент выберет день, когда склад
* или доставка не работают. Номера дней: 1=пн … 7=вс.
*/
const SCHEDULE_FALLBACK = {
deliveryDays: [1, 2, 3, 4, 5, 6],
pickupDays: [1, 2, 3, 4, 5],
smsDays: [1, 2, 3, 4, 5],
pickupOpen: "09:00",
pickupClose: "17:30",
pickupHalfEnd: "13:00",
pickupSameDayCutoff: "15:30",
deliveryOpen: "09:00",
deliveryClose: "21:00",
deliveryHalfEnd: "15:00",
};
const normalizeDays = (days, fallback) => {
const parsed = (Array.isArray(days) ? days : [])
.map((d) => Number(d))
.filter((d) => Number.isInteger(d) && d >= 1 && d <= 7);
return parsed.length ? parsed : fallback;
};
export const fetchBusinessSchedule = async () => {
if (!hasSupabaseConfig || !supabase?.rpc) {
return { ...SCHEDULE_FALLBACK };
}
try {
const { data, error } = await supabase.rpc("get_business_schedule");
if (error) throw error;
return {
deliveryDays: normalizeDays(data?.deliveryDays, SCHEDULE_FALLBACK.deliveryDays),
pickupDays: normalizeDays(data?.pickupDays, SCHEDULE_FALLBACK.pickupDays),
smsDays: normalizeDays(data?.smsDays, SCHEDULE_FALLBACK.smsDays),
pickupOpen: data?.pickupOpen || SCHEDULE_FALLBACK.pickupOpen,
pickupClose: data?.pickupClose || SCHEDULE_FALLBACK.pickupClose,
pickupHalfEnd: data?.pickupHalfEnd || SCHEDULE_FALLBACK.pickupHalfEnd,
pickupSameDayCutoff: data?.pickupSameDayCutoff || SCHEDULE_FALLBACK.pickupSameDayCutoff,
deliveryOpen: data?.deliveryOpen || SCHEDULE_FALLBACK.deliveryOpen,
deliveryClose: data?.deliveryClose || SCHEDULE_FALLBACK.deliveryClose,
deliveryHalfEnd: data?.deliveryHalfEnd || SCHEDULE_FALLBACK.deliveryHalfEnd,
};
} catch (e) {
console.warn("Не удалось получить график работы, беру значения по умолчанию", e);
return { ...SCHEDULE_FALLBACK };
}
};
export const fetchPickupWorkDays = async () => (await fetchBusinessSchedule()).pickupDays;
export const fetchDeliveryWorkDays = async () => (await fetchBusinessSchedule()).deliveryDays;
export const fetchDeliveryInvitation = async (token) => { export const fetchDeliveryInvitation = async (token) => {
if (!token) { if (!token) {
throw new Error("Token is required"); throw new Error("Token is required");

View File

@ -245,22 +245,176 @@ export const parseGroupDate = (value) => {
}; };
/** /**
* SMS delivery problem tone used for both table highlighting and filtering. * SMS status used for both table highlighting and filtering.
* Returns "ok" | "not_sent" | "not_delivered". * Returns "not_sent" | "sent" | "delivered" | "error".
*/ */
export const getSmsProblemTone = (group) => { export const getSmsStatus = (group) => {
if (!group) return "ok"; if (!group) return "not_sent";
const firstSms = group.firstSmsSentAt || group.first_sms_sent_at; const firstSms = group.firstSmsSentAt || group.first_sms_sent_at;
const secondSms = group.secondSmsSentAt || group.second_sms_sent_at; const secondSms = group.secondSmsSentAt || group.second_sms_sent_at;
const lastError = group.lastSmsError; const lastError = group.lastSmsError;
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → problem with delivery. // Sent an SMS (1st or 2nd) and it has a recorded delivery error → error.
if ((firstSms || secondSms) && lastError) return "not_delivered"; if ((firstSms || secondSms) && lastError) return "error";
// SMS should be expected but not sent at all yet. // SMS should be expected but not sent at all yet.
if (!firstSms && !secondSms) return "not_sent"; if (!firstSms && !secondSms) return "not_sent";
// Sent but no error recorded — assume delivered / in progress. // Sent but no error recorded — assume delivered / in progress.
return "delivered";
};
// Для обратной совместимости
export const getSmsProblemTone = (group) => {
const s = getSmsStatus(group);
if (s === "error") return "not_delivered";
if (s === "not_sent") return "not_sent";
return "ok"; return "ok";
}; };
// ─── Статус SMS: человеческие значения для фильтра ─────────────────
export const SMS_STATUS_META = {
not_sent: { label: "Не отправлено", tone: "neutral" },
sent: { label: "Отправлено", tone: "info" },
delivered: { label: "Доставлено", tone: "accent" },
error: { label: "Ошибка", tone: "danger" },
};
// ─── Причина неудачной отправки SMS ────────────────────────────────
// Коды SMS-сервиса (первая строка ответа) → человеческая причина.
// В логе лежит только цифра, поэтому расшифровываем её здесь.
export const SMS_ERROR_REASONS = {
"100": "сообщение в очереди SMS-сервиса",
"101": "сообщение передано оператору",
"102": "сообщение в пути",
"104": "истёк срок доставки сообщения",
"105": "сообщение удалено оператором",
"106": "сбой на телефоне абонента",
"107": "оператор не сообщил причину",
"108": "оператор отклонил сообщение",
"130": "лимит сообщений на этот номер в сутки",
"131": "лимит одинаковых сообщений в минуту",
"132": "лимит одинаковых сообщений в сутки",
"200": "неверный ключ доступа к SMS-сервису",
"201": "на счёте SMS-сервиса закончились средства",
"202": "неверный номер получателя",
"203": "пустой текст сообщения",
"230": "исчерпан общий суточный лимит аккаунта",
"231": "лимит одинаковых сообщений в минуту",
"232": "лимит одинаковых сообщений в сутки",
};
// Код нехватки средств — по нему показываем отдельное предупреждение.
export const SMS_FUNDS_ERROR_CODE = "201";
const smsErrorCode = (log) => {
if (!log) return "";
const direct = String(log.sms_code ?? "").trim();
if (direct) return direct.match(/\d{3}/)?.[0] || direct;
const fromText = String(log.error_message ?? "").trim();
return fromText.match(/\d{3}/)?.[0] || "";
};
/**
* Причина, по которой SMS не ушла — словами. Принимает строку лога
* (sms_campaign_log) либо значение lastSmsError у группы.
*/
export const getSmsFailureReason = (log) => {
if (!log) return null;
// В некоторых местах причина лежит просто строкой: "201" или "201\n"
if (typeof log === "string") {
const text = log.trim();
const onlyCode = text.match(/^(\d{3})$/)?.[1];
if (onlyCode && SMS_ERROR_REASONS[onlyCode]) return SMS_ERROR_REASONS[onlyCode];
// Технические строки из планировщика вида «Not delivered after 2 attempts
// (code=106)» переводим в человеческие: код объясняем словами.
const embedded = text.match(/code\s*=\s*(\d{3})/i)?.[1];
if (embedded && SMS_ERROR_REASONS[embedded]) {
return `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`;
}
// «Не доставлено после N попыток» без кода — говорим так, как есть.
const attempts = text.match(/after\s+(\d+)\s+attempts?/i);
if (/not delivered|delivery failed|failed after/i.test(text)) {
return attempts
? `клиент не получил сообщение за ${attempts[1]} попытки — телефон недоступен или оператор не доставил`
: "клиент не получил сообщение — телефон недоступен или оператор не доставил";
}
if (/limit exceeded/i.test(text)) {
return embedded && SMS_ERROR_REASONS[embedded]
? `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`
: "сработал лимит отправки — SMS-сервис временно блокирует рассылку";
}
return text.slice(0, 120) || null;
}
const code = smsErrorCode(log);
if (code && SMS_ERROR_REASONS[code]) return SMS_ERROR_REASONS[code];
const raw = String(log.error_message ?? "").trim();
if (code) return `код ${code}`;
if (!raw) return null;
return raw.slice(0, 120);
};
/** Нехватка средств на счёте SMS-сервиса — самая частая и заметная причина. */
export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CODE;
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
// ВАЖНО: те же значения приходят из базы (функция autocall_status(order_groups)) —
// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся.
// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры.
export const AUTOCALL_STATUS_META = {
not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
no_input_day: { label: "Не выбрал день", short: "не выбрал день", tone: "warning", icon: "📅" },
no_input_half: { label: "Не выбрал время", short: "не выбрал время", tone: "warning", icon: "🕐" },
no_input_both: { label: "Не выбрал день и время", short: "не выбрал день и время", tone: "warning", icon: "🤐" },
manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" },
opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" },
excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" },
};
/** Порядок для выпадающих списков — один список на всё приложение. */
export const AUTOCALL_STATUS_OPTIONS = [
{ value: "not_called", label: "Не звонили" },
{ value: "confirmed", label: "Согласовано" },
{ value: "no_answer", label: "Не дозвонились" },
{ value: "no_input_day", label: "Не выбрал день" },
{ value: "no_input_half", label: "Не выбрал время" },
{ value: "no_input_both", label: "Не выбрал день и время" },
{ value: "manual", label: "Нужен менеджер" },
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
{ value: "excluded", label: "Не звоним (исключение)" },
];
/**
* Что именно клиент не выбрал: день, время или и то и другое.
*
* Считаем по всем звонкам заказа, а не только по последнему: если в одном
* звонке клиент не выбрал день, а в другом день выбрал, но не успел время —
* честнее сказать «не выбрал день и время», чем показывать половину картины.
*/
export const resolveNoInputKind = (calls = []) => {
const outcomes = new Set((calls || []).map((c) => c?.outcome));
const day = outcomes.has("no_input_day");
const half = outcomes.has("no_input_half");
if (day && half) return "no_input_both";
if (half) return "no_input_half";
if (day) return "no_input_day";
return null;
};
export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => {
if (optOut) return "opt_out";
if (exclusionReason) return "excluded";
if (!Array.isArray(calls) || calls.length === 0) return "not_called";
const last = calls[0];
if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed";
if (last.outcome === "no_answer") return "no_answer";
if (["no_input_day", "no_input_half"].includes(last.outcome)) {
return resolveNoInputKind(calls) || "no_input_day";
}
if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual";
return "not_called";
};
export const filterOrderGroups = (groups, filters = {}) => { export const filterOrderGroups = (groups, filters = {}) => {
const query = normalizeDate(filters.query).trim().toLowerCase(); const query = normalizeDate(filters.query).trim().toLowerCase();
const status = filters.status || "all"; const status = filters.status || "all";
@ -270,6 +424,7 @@ export const filterOrderGroups = (groups, filters = {}) => {
const dateTo = normalizeDate(filters.dateTo); const dateTo = normalizeDate(filters.dateTo);
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all"); const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
const smsStatus = filters.smsStatus || "all"; const smsStatus = filters.smsStatus || "all";
const autocallStatus = filters.autocallStatus || "all";
const isWithinDateRange = (group) => { const isWithinDateRange = (group) => {
const deliveryDate = parseGroupDate(getDeliveryDate(group)); const deliveryDate = parseGroupDate(getDeliveryDate(group));
@ -362,9 +517,20 @@ export const filterOrderGroups = (groups, filters = {}) => {
} }
if (smsStatus !== "all") { if (smsStatus !== "all") {
const tone = getSmsProblemTone(group); const status = getSmsStatus(group);
if (smsStatus === "problem_not_sent" && tone !== "not_sent") return false; if (smsStatus === "not_sent" && status !== "not_sent") return false;
if (smsStatus === "problem_not_delivered" && tone !== "not_delivered") return false; if (smsStatus === "sent" && status !== "sent") return false;
if (smsStatus === "delivered" && status !== "delivered") return false;
if (smsStatus === "error" && status !== "error") return false;
}
// Фильтр «Статус автодозвона»
if (autocallStatus !== "all") {
const acStatus = group.autocall_status || group.autocallStatus || resolveAutocallStatus([], {
optOut: group.autocall_opt_out,
exclusionReason: group.autocall_exclusion_reason,
});
if (acStatus !== autocallStatus) return false;
} }
if (!query) { if (!query) {
@ -397,7 +563,7 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage }, { value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, { value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
{ value: "status:manual_required", label: "Требует ручной обработки" }, { value: "status:manual_required", label: "Нужен менеджер" },
{ value: "status:ready_for_notification", label: "Готово к уведомлению" }, { value: "status:ready_for_notification", label: "Готово к уведомлению" },
{ value: "status:ready_to_launch", label: "Готово к запуску" }, { value: "status:ready_to_launch", label: "Готово к запуску" },
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent }, { value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },

View File

@ -0,0 +1,90 @@
/**
* @file orderGroupViews.smsReasons.test.js
* @description Причина неудачной отправки SMS должна быть понятна человеку:
* в логе лежит код сервиса (201), а показывать нужно «закончились средства».
*/
import { describe, it, expect } from "vitest";
import {
getSmsFailureReason,
isSmsFundsError,
SMS_ERROR_REASONS,
getSmsProblemTone,
} from "./orderGroupViews";
describe("getSmsFailureReason", () => {
it("расшифровывает код 201 — закончились средства", () => {
expect(getSmsFailureReason({ sms_code: "201", error_message: "201" }))
.toBe("на счёте SMS-сервиса закончились средства");
});
it("понимает код, пришедший строкой с переводом строки", () => {
expect(getSmsFailureReason("201\n")).toBe("на счёте SMS-сервиса закончились средства");
});
it("берёт код из текста ошибки, если колонка кода пуста", () => {
expect(getSmsFailureReason({ sms_code: null, error_message: "202" }))
.toBe("неверный номер получателя");
});
it("неизвестный код показывает как код, а не как пустоту", () => {
expect(getSmsFailureReason({ sms_code: "999" })).toBe("код 999");
});
it("без данных возвращает null", () => {
expect(getSmsFailureReason(null)).toBeNull();
expect(getSmsFailureReason({})).toBeNull();
});
it("все коды из таблицы расшифрованы человеческими словами", () => {
for (const [code, text] of Object.entries(SMS_ERROR_REASONS)) {
expect(text).not.toMatch(/^\d{3}$/);
expect(getSmsFailureReason({ sms_code: code })).toBe(text);
}
});
});
describe("isSmsFundsError", () => {
it("распознаёт нехватку средств", () => {
expect(isSmsFundsError({ sms_code: "201" })).toBe(true);
expect(isSmsFundsError({ error_message: "201\n" })).toBe(true);
});
it("не считает нехваткой средств другие ошибки", () => {
expect(isSmsFundsError({ sms_code: "202" })).toBe(false);
expect(isSmsFundsError(null)).toBe(false);
});
});
describe("технические строки из планировщика переводятся в человеческие", () => {
it("«Second SMS failed after 2 attempts (code=106)» — код объяснён словами", () => {
const reason = getSmsFailureReason("Second SMS failed after 2 attempts (code=106)");
expect(reason).toContain("сбой на телефоне абонента");
expect(reason).toContain("код 106");
});
it("«Not delivered after 2 attempts» без кода — говорим прямо", () => {
const reason = getSmsFailureReason("Not delivered after 2 attempts");
expect(reason).toContain("не получил сообщение");
expect(reason).toContain("2 попытки");
});
it("«Second SMS limit exceeded (code=232)» — лимит словами", () => {
const reason = getSmsFailureReason("Second SMS limit exceeded (code=232)");
expect(reason).toContain("лимит одинаковых сообщений в сутки");
});
it("одна цифра 201 с переводом строки по-прежнему расшифровывается", () => {
expect(getSmsFailureReason("201\n")).toBe("на счёте SMS-сервиса закончились средства");
});
});
describe("подсветка SMS в таблице остаётся прежней", () => {
it("без отправленных SMS — «не отправлено»", () => {
expect(getSmsProblemTone({})).toBe("not_sent");
});
it("отправлено с ошибкой — «не доставлено»", () => {
expect(getSmsProblemTone({ firstSmsSentAt: "2026-09-13T06:00:00Z", lastSmsError: "201" }))
.toBe("not_delivered");
});
});

View File

@ -0,0 +1,43 @@
/**
* @file smsBalance.js
* @description Баланс счёта SMS-сервиса — один источник на всё приложение.
*
* Раньше главная страница брала баланс живым запросом (функция sms-balance),
* а страница «Коммуникации» — сохранённое значение sms_campaign_settings.last_balance,
* которое обновляется только во время рассылки. Из-за этого две страницы показывали
* разные суммы. Теперь обе спрашивают одно и то же — живой баланс; сохранённое
* значение остаётся только запасным вариантом, если запрос не прошёл.
*/
const BALANCE_LOW_RUB = 100;
/** Живой баланс sms.ru через edge-функцию (ключ сервиса живёт только на сервере). */
export const fetchSmsBalance = async () => {
const url = import.meta.env.VITE_SUPABASE_URL || "";
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
if (!url) return null;
try {
const resp = await fetch(`${url}/functions/v1/sms-balance`, {
method: "POST",
headers: { "Content-Type": "application/json", ...(anonKey ? { apikey: anonKey } : {}) },
body: "{}",
});
const payload = await resp.json();
if (!payload?.ok) return null;
return { balance: payload.balance, lowBalance: payload.threshold ?? BALANCE_LOW_RUB, live: true };
} catch {
return null;
}
};
/** Живой баланс, а если он недоступен — сохранённый (и пометка, что он не свежий). */
export const resolveSmsBalance = async (fallbackBalance = null) => {
const live = await fetchSmsBalance();
if (live) return live;
if (Number.isFinite(fallbackBalance)) {
return { balance: fallbackBalance, lowBalance: BALANCE_LOW_RUB, live: false };
}
return null;
};
export const SMS_LOW_BALANCE_RUB = BALANCE_LOW_RUB;

View File

@ -0,0 +1,106 @@
/**
* @file smsProblemSummary.js
* @description Складывает неудачные отправки SMS в одну понятную причину.
*
* В таблице видно только «SMS не отправлено», а причина в логе — код сервиса.
* Здесь считаем: сколько сообщений, сколько заказов, за какой период, что
* случилось чаще всего — и главное, актуальна ли проблема до сих пор (по балансу
* счёта SMS-сервиса). Пополнили счёт — предупреждение гасим, а не держим месяцами.
*/
import { getSmsFailureReason } from "./orderGroupViews";
/** Через сколько дней после последней ошибки показываем, что всё наладилось.
* Короткий срок намеренно: «SMS снова уходят» — сообщение на день-два, а не
* постоянный баннер на главной (владелец закрывал его, а он возвращался). */
export const RESOLVED_VISIBLE_DAYS = 2;
/**
* @param {Array} rows строки sms_campaign_log со статусами ошибок
* @param {Array<string>} visibleIds id заказов, которые сейчас видны в таблице
* @param {{balance?: number|null, lowBalance?: number}} options баланс счёта SMS-сервиса
* @returns {null|object} null — говорить не о чем
*/
export const summarizeSmsFailures = (rows = [], visibleIds = [], options = {}) => {
const { balance = null, lowBalance = 100 } = options;
const balanceKnown = Number.isFinite(balance);
const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
const reasons = new Map();
const lastByGroup = new Map();
let firstAt = null;
let lastAt = null;
for (const row of list) {
const reason = getSmsFailureReason(row) || "причина не указана";
reasons.set(reason, (reasons.get(reason) || 0) + 1);
if (row.order_group_id && !lastByGroup.has(row.order_group_id)) {
lastByGroup.set(row.order_group_id, row);
}
if (row.created_at) {
if (!firstAt || row.created_at < firstAt) firstAt = row.created_at;
if (!lastAt || row.created_at > lastAt) lastAt = row.created_at;
}
}
const [reason, topMessages] = list.length
? [...reasons.entries()].sort((a, b) => b[1] - a[1])[0]
: [null, 0];
const hasFundsReason = !!reason && /закончились средства/.test(reason);
const visible = new Set(visibleIds);
// Базовая сводка по логу (нужна в любом состоянии).
const base = {
reason,
topMessages,
messages: list.length,
groups: lastByGroup.size,
affectedInTable: [...lastByGroup.keys()].filter((id) => visible.has(id)).length,
hasVisibleRows: visible.size > 0,
hasFundsReason,
firstAt,
lastAt,
balance,
balanceKnown,
lowBalanceThreshold: lowBalance,
};
const within = (days) => {
if (!lastAt) return false;
return Date.now() - new Date(lastAt).getTime() <= days * 86400000;
};
let kind = null;
if (!list.length) {
// Ошибок нет, но денег на счёте мало — предупреждаем заранее.
kind = balanceKnown && balance < lowBalance ? "low_balance" : null;
} else if (hasFundsReason && balanceKnown && balance >= lowBalance) {
// Деньги пополнены — проблема снята, показываем это недолго и без нагнетания.
kind = within(RESOLVED_VISIBLE_DAYS) ? "resolved" : null;
} else if (hasFundsReason) {
kind = "funds";
} else {
kind = "other";
}
if (!kind) return null;
return {
...base,
kind,
// Отпечаток — только то, что делает сообщение НОВЫМ: вид проблемы, причина и
// время последней ошибки. Баланс сюда НЕ входит: он меняется от каждой отправки,
// из-за него закрытый баннер возвращался на следующий день.
signature: [kind, reason || "", lastAt || ""].join("|"),
};
};
/** «сообщение / сообщения / сообщений», «заказ / заказа / заказов». */
export const plural = (n, one, few, many) => {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
};

View File

@ -0,0 +1,116 @@
/**
* @file smsProblemSummary.test.js
* @description Что показывать над таблицей: причина недоставки, «всё наладилось»,
* предупреждение о низком балансе — и когда не показывать ничего.
*/
import { describe, it, expect } from "vitest";
import { summarizeSmsFailures } from "./smsProblemSummary";
const row = (over = {}) => ({
order_group_id: "g1",
campaign_type: "first_sms",
status: "send_failed",
sms_code: "201",
error_message: "201\n",
created_at: "2026-09-10T10:06:11Z",
...over,
});
const daysAgo = (days) => new Date(Date.now() - days * 86400000).toISOString();
describe("summarizeSmsFailures — причина недоставки", () => {
it("объясняет нехватку средств и считает заказы с сообщениями", () => {
const summary = summarizeSmsFailures([
row({ order_group_id: "g1", created_at: "2026-09-10T10:06:11Z" }),
row({ order_group_id: "g2", created_at: "2026-09-13T06:03:03Z" }),
row({ order_group_id: "g2", created_at: "2026-09-13T06:03:04Z" }),
], ["g1", "g2"]);
expect(summary.kind).toBe("funds");
expect(summary.reason).toBe("на счёте SMS-сервиса закончились средства");
expect(summary.groups).toBe(2);
expect(summary.messages).toBe(3);
expect(summary.affectedInTable).toBe(2);
});
it("берёт самую частую причину, когда их несколько", () => {
const summary = summarizeSmsFailures([
row({ order_group_id: "g1", sms_code: "202", error_message: "202" }),
row({ order_group_id: "g2" }),
row({ order_group_id: "g3" }),
], []);
expect(summary.reason).toBe("на счёте SMS-сервиса закончились средства");
expect(summary.topMessages).toBe(2);
});
it("другая причина — отдельный вид блока", () => {
const summary = summarizeSmsFailures([row({ sms_code: "202", error_message: "202" })], []);
expect(summary.kind).toBe("other");
});
});
describe("summarizeSmsFailures — актуальность по балансу", () => {
it("счёт пополнен: вместо красного предупреждения — «всё наладилось»", () => {
const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 12717.45 });
expect(summary.kind).toBe("resolved");
expect(summary.balance).toBe(12717.45);
});
it("денег на счёте нет: проблема актуальна", () => {
const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
expect(summary.kind).toBe("funds");
});
it("баланс узнать не удалось — считаем проблему актуальной", () => {
const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: null });
expect(summary.kind).toBe("funds");
});
it("старая история не висит месяцами: неделя прошла — не показываем", () => {
const summary = summarizeSmsFailures([row({ created_at: daysAgo(8) })], [], { balance: 12717.45 });
expect(summary).toBeNull();
});
it("«всё наладилось» живёт день-два и гаснет само", () => {
const fresh = summarizeSmsFailures([row({ created_at: daysAgo(0.5) })], [], { balance: 12717.45 });
expect(fresh.kind).toBe("resolved");
const stale = summarizeSmsFailures([row({ created_at: daysAgo(3) })], [], { balance: 12717.45 });
expect(stale).toBeNull();
});
it("отпечаток не зависит от суммы на счёте — закрытый баннер не возвращается", () => {
const a = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 12717.45 });
const b = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 11122.69 });
expect(a.signature).toBe(b.signature);
});
it("ошибок нет, но денег мало — предупреждаем заранее", () => {
const summary = summarizeSmsFailures([], [], { balance: 55 });
expect(summary.kind).toBe("low_balance");
expect(summary.balance).toBe(55);
expect(summary.reason).toBeNull();
});
it("ошибок нет и денег достаточно — блок не нужен", () => {
expect(summarizeSmsFailures([], [], { balance: 12717.45 })).toBeNull();
expect(summarizeSmsFailures(null, [], { balance: null })).toBeNull();
});
});
describe("отпечаток состояния для закрытия блока", () => {
it("меняется, когда проблема другая — закрытый блок вернётся с новой ошибкой", () => {
const first = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
const second = summarizeSmsFailures([row({ created_at: daysAgo(2) })], [], { balance: 42 });
const third = summarizeSmsFailures([row({ created_at: daysAgo(1), sms_code: "202", error_message: "202" })], [], { balance: 42 });
expect(first.signature).not.toBe(second.signature);
expect(first.signature).not.toBe(third.signature);
});
it("одинаковые данные — одинаковый отпечаток (повторно не всплывает)", () => {
const a = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
const b = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 });
expect(a.signature).toBe(b.signature);
});
});

View File

@ -231,6 +231,12 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
deliveryDateSource: row.delivery_date_source || null, deliveryDateSource: row.delivery_date_source || null,
autocallOptOut: !!row.autocall_opt_out,
autocall_opt_out: !!row.autocall_opt_out,
autocallStatus: row.autocall_status || null,
autocall_status: row.autocall_status || null,
autocallExclusionReason: row.autocall_exclusion_reason || null,
autocall_exclusion_reason: row.autocall_exclusion_reason || null,
deliveryType: effectiveDeliveryType, deliveryType: effectiveDeliveryType,
pickupDate: row.pickup_date || null, pickupDate: row.pickup_date || null,
pickupTimeSlot: row.pickup_time_slot || null, pickupTimeSlot: row.pickup_time_slot || null,
@ -294,7 +300,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
}; };
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`; export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment, autocall_exclusion_reason, autocall_status`;
// Delivery statuses treated as "finished" — hidden by default in the board. // Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
@ -610,7 +616,7 @@ export const fetchOrderGroups = async ({
if (!forceRefresh) { if (!forceRefresh) {
const cached = orderGroupsCache.get(cacheKey); const cached = orderGroupsCache.get(cacheKey);
if (cached) return cached; if (cached) return { data: cached, error: null };
} }
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {

View File

@ -17,7 +17,6 @@ const OUTCOMES = new Set([
"no_input_day", "no_input_day",
"no_input_half", "no_input_half",
"operator", "operator",
"opt_out",
"invalid_number", "invalid_number",
"error", "error",
]); ]);
@ -29,6 +28,8 @@ type AutocallBody = {
customer_phone?: string; customer_phone?: string;
attempt_no?: number; attempt_no?: number;
scenario?: string; scenario?: string;
call_type?: string;
chosen_date?: string;
call_started_at?: string; call_started_at?: string;
answered_at?: string | null; answered_at?: string | null;
duration_sec?: number | null; duration_sec?: number | null;
@ -50,6 +51,16 @@ function addDays(date: Date, days: number): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
/** День по-человечески: сегодня / завтра / послезавтра, иначе дата дд.мм.гггг. */
function relativeDayLabel(dateKey: string): string {
const today = new Date();
for (const [offset, label] of [[0, "сегодня"], [1, "завтра"], [2, "послезавтра"]] as const) {
if (addDays(today, offset) === dateKey) return label;
}
const [y, m, d] = dateKey.split("-");
return y && m && d ? `${d}.${m}.${y}` : dateKey;
}
Deno.serve(async (request) => { Deno.serve(async (request) => {
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
const cors = getCorsHeaders(request, "private"); const cors = getCorsHeaders(request, "private");
@ -81,6 +92,7 @@ Deno.serve(async (request) => {
const campaignType = body.campaign_type || "autocall_slot"; const campaignType = body.campaign_type || "autocall_slot";
const attemptNo = body.attempt_no ?? 1; const attemptNo = body.attempt_no ?? 1;
const scenario = body.scenario || "A"; const scenario = body.scenario || "A";
const callType = body.call_type === "pkp" ? "pkp" : "dlv";
const wasTest = body.was_test_mode ?? false; const wasTest = body.was_test_mode ?? false;
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ── // ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
@ -109,6 +121,7 @@ Deno.serve(async (request) => {
customer_phone: body.customer_phone, customer_phone: body.customer_phone,
attempt_no: attemptNo, attempt_no: attemptNo,
scenario, scenario,
call_type: callType,
call_started_at: body.call_started_at || new Date().toISOString(), call_started_at: body.call_started_at || new Date().toISOString(),
answered_at: body.answered_at || null, answered_at: body.answered_at || null,
duration_sec: body.duration_sec ?? null, duration_sec: body.duration_sec ?? null,
@ -148,32 +161,41 @@ Deno.serve(async (request) => {
// ── confirmed*: записываем слот ── // ── confirmed*: записываем слот ──
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) { if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
const dayOffset = body.dtmf_day === 2 ? 2 : 1; // День называет сам диалог (chosen_date) — робот перечисляет конкретные
const deliveryDate = addDays(new Date(), dayOffset); // дни окна самовывоза, поэтому «1/2/3» само по себе дату не задаёт.
// Если дата не пришла (старые call-файлы) — считаем по цифре дня.
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
? (body.chosen_date as string)
: null;
const dayOffset = body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0;
const deliveryDate = chosenDate || addDays(new Date(), body.dtmf_day ? dayOffset : 1);
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null; const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
const dayLabel = body.dtmf_day ? DAY_LABEL[body.dtmf_day] : "завтра"; const dayLabel = chosenDate
? relativeDayLabel(chosenDate)
: body.dtmf_day
? DAY_LABEL[body.dtmf_day]
: "завтра";
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`; const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
const update: Record<string, unknown> = { const update: Record<string, unknown> = {
delivery_date_source: "autocall", delivery_date_source: "autocall",
delivery_status: "agreed", delivery_status: "agreed",
notification_status: "confirmed", notification_status: "confirmed",
delivery_date: deliveryDate,
called: true, called: true,
called_at: new Date().toISOString(), called_at: new Date().toISOString(),
called_comment: comment, called_comment: comment,
next_notification_check_at: null, next_notification_check_at: null,
}; };
if (timeSlot) update.delivery_time = timeSlot;
if (isPickup) { if (isPickup) {
update.pickup_date = deliveryDate; update.pickup_date = deliveryDate;
if (timeSlot) update.pickup_time_slot = timeSlot; if (timeSlot) update.pickup_time_slot = timeSlot;
} else {
update.delivery_date = deliveryDate;
if (timeSlot) update.delivery_time = timeSlot;
} }
await supabase.from("order_groups").update(update).eq("id", orderGroupId); await supabase.from("order_groups").update(update).eq("id", orderGroupId);
} }
// ── Эскалация ── // ── Эскалация ──
else if (["operator", "opt_out", "invalid_number"].includes(body.outcome)) { else if (["operator", "invalid_number"].includes(body.outcome)) {
const update: Record<string, unknown> = { const update: Record<string, unknown> = {
notification_status: "manual_required", notification_status: "manual_required",
called: true, called: true,
@ -181,14 +203,14 @@ Deno.serve(async (request) => {
called_comment: called_comment:
body.outcome === "operator" body.outcome === "operator"
? "Просил менеджера" ? "Просил менеджера"
: body.outcome === "opt_out" : "Неверный номер",
? "Отказался от автозвонков"
: "Неверный номер",
}; };
if (body.outcome === "opt_out") update.autocall_opt_out = true;
await supabase.from("order_groups").update(update).eq("id", orderGroupId); await supabase.from("order_groups").update(update).eq("id", orderGroupId);
} }
// ── no_answer / no_input: решаем, эскалировать ли ── // ── no_answer / no_input: решаем, эскалировать ли ──
// Пороги берём из настроек кампании (админка → Автодозвон): раньше здесь
// стояли константы 2 и 5, поэтому поля «Не нажал ×N → менеджер» и
// «Всего попыток» в интерфейсе ни на что не влияли.
else { else {
const { count: noInputCount } = await supabase const { count: noInputCount } = await supabase
.from("autocall_log") .from("autocall_log")
@ -200,8 +222,14 @@ Deno.serve(async (request) => {
.select("id", { count: "exact", head: true }) .select("id", { count: "exact", head: true })
.eq("order_group_id", orderGroupId); .eq("order_group_id", orderGroupId);
const noInputEscalate = 2; // из настроек, но пока константа const { data: settings } = await supabase
const maxAttempts = 5; // из настроек, но пока константа .from("sms_campaign_settings")
.select("no_input_escalate_after, max_attempts")
.eq("campaign_type", "autocall_slot")
.maybeSingle();
const noInputEscalate = Number(settings?.no_input_escalate_after ?? 2);
const maxAttempts = Number(settings?.max_attempts ?? 5);
const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts; const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts;
if (escalate) { if (escalate) {
await supabase.from("order_groups").update({ await supabase.from("order_groups").update({

View File

@ -0,0 +1,72 @@
/** Баланс счёта SMS-сервиса — для предупреждения в интерфейсе.
*
* Зачем: 10–13 сентября рассылки падали с кодом 201 («не хватает средств»),
* и об этом никто не знал. Ключ доступа к SMS живёт только на сервере, поэтому
* баланс отдаём отдельной функцией, а не читаем ключ в браузере.
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const ALLOWED_ORIGINS = [
"https://dost.supersamsev.ru",
"https://supa.supersamsev.ru",
"https://dev.mkn8n.ru",
"http://localhost:5173",
];
const BALANCE_URL = "https://sms.ru/my/balance";
// Ниже этого остатка предупреждаем заранее, до остановки отправки.
const LOW_BALANCE_RUB = 100;
const cors = (origin: string) => ({
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
"Access-Control-Allow-Methods": "POST,GET,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization,apikey",
});
Deno.serve(async (req: Request) => {
const origin = req.headers.get("origin") || "";
const headers = { ...cors(origin), "Content-Type": "application/json" };
if (req.method === "OPTIONS") return new Response(null, { headers });
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
const sb = createClient(supabaseUrl, serviceKey);
const { data: settings } = await sb
.from("sms_campaign_settings")
.select("sms_api_id")
.eq("campaign_type", "first_sms")
.single();
const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || "";
if (!apiId) {
return new Response(JSON.stringify({ ok: false, error: "api_id not configured" }), { status: 500, headers });
}
const resp = await fetch(`${BALANCE_URL}?api_id=${encodeURIComponent(apiId)}&json=1`);
const text = await resp.text();
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(text);
} catch {
return new Response(JSON.stringify({ ok: false, error: "unexpected response", raw: text.slice(0, 200) }), { status: 200, headers });
}
if (payload.status !== "OK") {
return new Response(JSON.stringify({ ok: false, code: payload.status_code ?? null, raw: text.slice(0, 200) }), { status: 200, headers });
}
const balance = Number(payload.balance);
return new Response(JSON.stringify({
ok: true,
balance,
low: balance < LOW_BALANCE_RUB,
threshold: LOW_BALANCE_RUB,
}), { headers });
} catch (e) {
return new Response(JSON.stringify({ ok: false, error: String(e) }), { status: 500, headers });
}
});

View File

@ -0,0 +1,84 @@
-- ============================================================
-- SuperSam: Автодозвон — исключения номеров/префиксов (Фаза 8)
-- 08.09.2026. Аддитивная, безопасна для прод-базы.
-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f <file>
-- ============================================================
-- 1) Таблица исключений
create table if not exists public.autocall_exclusions (
id bigint generated always as identity primary key,
rule_type text not null check (rule_type in ('prefix','phone')),
value text not null, -- '7918' или '79189521296'
reason text not null default '', -- 'МТС — кнопки не проходят'
active boolean not null default true,
created_by text, -- email логиста/админа, кто добавил
created_at timestamptz not null default now(),
unique (rule_type, value)
);
create index if not exists idx_autocall_excl_active on public.autocall_exclusions(active) where active;
comment on table public.autocall_exclusions is 'Исключения автодозвона: префиксы/номера, на которые робот не звонит';
grant select on public.autocall_exclusions to authenticated;
-- запись только для админов/логистов через RLS (включаем RLS)
alter table public.autocall_exclusions enable row level security;
-- политики: читать все залогиненные; писать только admin/mega_admin/logistician
do $$
begin
if not exists (select 1 from pg_policies where tablename='autocall_exclusions' and policyname='autocall_excl_select') then
create policy autocall_excl_select on public.autocall_exclusions
for select to authenticated using (true);
end if;
if not exists (select 1 from pg_policies where tablename='autocall_exclusions' and policyname='autocall_excl_write') then
create policy autocall_excl_write on public.autocall_exclusions
for all to authenticated
using (
exists (
select 1 from public.users u
where u.id = auth.uid()
and u.role in ('mega_admin','admin','logistician')
)
)
with check (
exists (
select 1 from public.users u
where u.id = auth.uid()
and u.role in ('mega_admin','admin','logistician')
)
);
end if;
end $$;
-- 2) RPC: проверка номера по исключениям (нормализуем телефон до 11 цифр без +7/8)
create or replace function public.autocall_exclusion_match(p_phone text)
returns table(rule_type text, value text, reason text)
language sql stable security definer
set search_path = public
as $$
with norm as (
select case
when length(regexp_replace(coalesce(p_phone,''), '\D', '', 'g')) = 11
then regexp_replace(coalesce(p_phone,''), '\D', '', 'g')
when length(regexp_replace(coalesce(p_phone,''), '\D', '', 'g')) = 10
then '7' || regexp_replace(coalesce(p_phone,''), '\D', '', 'g')
else regexp_replace(coalesce(p_phone,''), '\D', '', 'g')
end as phone
)
select e.rule_type, e.value, e.reason
from public.autocall_exclusions e, norm n
where e.active
and (
(e.rule_type = 'phone' and n.phone = e.value)
or
(e.rule_type = 'prefix' and n.phone like e.value || '%')
)
limit 1;
$$;
grant execute on function public.autocall_exclusion_match(text) to authenticated;
grant execute on function public.autocall_exclusion_match(text) to service_role;
-- 3) Стартовое правило — МТС диапазон, который мы точно знаем (неактивно по умолчанию? ставим активным — пользователь просил)
insert into public.autocall_exclusions (rule_type, value, reason, active, created_by)
values ('prefix', '7918952', 'МТС — DTMF не проходит (Miranda)', true, 'system')
on conflict do nothing;

View File

@ -0,0 +1,42 @@
-- ============================================================
-- SuperSam: Автодозвон — исключения в view реестра (Фаза 8b)
-- 08.09.2026. Аддитивная. Дополняет order_group_comms_flags полями
-- autocall_excluded / autocall_exclusion_reason, чтобы фронт показывал
-- бейдж «не звоним (исключение)» без отдельного запроса.
-- ============================================================
drop view if exists public.order_group_comms_flags;
create view public.order_group_comms_flags as
select
og.id,
og.autocall_opt_out,
og.delivery_date_source,
og.notification_status,
og.delivery_status,
og.customer_phone_normalized,
(select l.outcome from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_outcome,
(select l.changed from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_changed,
(select l.created_at from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_at,
(select count(*) from public.autocall_log l where l.order_group_id = og.id) as call_attempts,
(select count(*) from public.autocall_log l where l.order_group_id = og.id
and l.outcome in ('no_input_day','no_input_half')) as no_input_count,
-- Исключение автодозвона (префикс/номер)
(select e.reason from public.autocall_exclusions e
where e.active
and (
(e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value)
or
(e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%')
)
limit 1) as autocall_exclusion_reason,
(select e.value from public.autocall_exclusions e
where e.active
and (
(e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value)
or
(e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%')
)
limit 1) as autocall_exclusion_value
from public.order_groups og;
grant select on public.order_group_comms_flags to authenticated;