Compare commits
42 Commits
95af771596
...
2067ff7d89
| Author | SHA1 | Date |
|---|---|---|
|
|
2067ff7d89 | |
|
|
c58f2b0629 | |
|
|
775a72ea29 | |
|
|
512aab9ff2 | |
|
|
e35b3d885f | |
|
|
dacd43165c | |
|
|
b965216181 | |
|
|
4192e44998 | |
|
|
5c98a54895 | |
|
|
7ab3debbb3 | |
|
|
aeb4bbf63f | |
|
|
833d2dcd44 | |
|
|
d9ded99e81 | |
|
|
ed89222834 | |
|
|
8e9a0f6dab | |
|
|
5a71fbceba | |
|
|
47b3ccf28f | |
|
|
44f58ce9e7 | |
|
|
3706f64ca7 | |
|
|
dc262d2be7 | |
|
|
6ebdc41667 | |
|
|
f9395d7d05 | |
|
|
1660cced66 | |
|
|
1a310e5f5a | |
|
|
11167516a3 | |
|
|
794c3ce5e3 | |
|
|
270ab39abc | |
|
|
139c895290 | |
|
|
f62a6b525b | |
|
|
feb3fc1b07 | |
|
|
c4e2c4a181 | |
|
|
46a3609233 | |
|
|
73b9512f15 | |
|
|
52dc913fa7 | |
|
|
9d1c74eda0 | |
|
|
f378fd05ef | |
|
|
c7a35cdd29 | |
|
|
04f78f7367 | |
|
|
587e12f562 | |
|
|
64bcda8eed | |
|
|
1552c21d99 | |
|
|
5dbeae84f4 |
|
|
@ -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 ==="
|
||||
|
|
@ -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
|
||||
|
|
@ -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 =====
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
; ===== SuperSam автодозвон: автозагрузка модулей (добавлено 2026-09-01) =====
|
||||
; Без этого ночной reboot 03:33 сносит pbx_spool (call-файлы не подхватываются).
|
||||
load = pbx_spool.so
|
||||
load = res_agi.so
|
||||
; ===== конец =====
|
||||
|
|
@ -68,6 +68,49 @@ load = res_agi.so
|
|||
; Это чинит ночной 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 ---
|
||||
|
||||
=====================================================================
|
||||
## ПРОВЕРКА ПОСЛЕ ПРИМЕНЕНИЯ (на АТС)
|
||||
=====================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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` всех файлов между собой и с ожидаемым.
|
||||
|
|
@ -27,7 +27,28 @@ OUT="${RES}-${UNIQ}.txt"
|
|||
say () { echo "$1"; read -r resp; echo "$(date +%T) $1 -> $resp" >> "$LOG"; RESP="$resp"; }
|
||||
digit () { # $1=озвучка, $2=таймаут мс -> DIGIT
|
||||
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.
|
||||
getvar () { # $1=имя -> VAR
|
||||
|
|
@ -36,6 +57,26 @@ getvar () { # $1=имя -> VAR
|
|||
}
|
||||
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
|
||||
# не видит их как bash-переменные — только через GET VARIABLE.
|
||||
getvar API_URL; API_URL=$VAR
|
||||
|
|
@ -44,6 +85,16 @@ getvar GROUP_ID; GROUP_ID=$VAR
|
|||
getvar ATTEMPT; ATTEMPT=$VAR
|
||||
getvar TEST_MODE; TEST_MODE=$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 умирает молча, финальной записи не будет).
|
||||
|
|
@ -63,7 +114,7 @@ save () {
|
|||
# Отправляем в БД сразу при финальном исходе (клиент может бросить трубку
|
||||
# в любой момент — результат должен успеть уйти). Идемпотентность по uniqueid.
|
||||
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 ;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -83,6 +134,8 @@ send_result () {
|
|||
\"customer_phone\":\"${CUSTOMER_PHONE:-}\",
|
||||
\"attempt_no\":${ATTEMPT:-1},
|
||||
\"scenario\":\"$SCENARIO\",
|
||||
\"call_type\":\"${TYPE:-dlv}\",
|
||||
\"chosen_date\":\"${CHOSEN_DATE:-}\",
|
||||
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||
\"duration_sec\":0,
|
||||
|
|
@ -104,6 +157,19 @@ save
|
|||
say "ANSWER"
|
||||
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: подтверждение уже выбранного слота ───────────────────────────
|
||||
if [ "$SCENARIO" = "C" ]; then
|
||||
play "ru/ss_c_intro_${TYPE}"
|
||||
|
|
@ -121,6 +187,183 @@ if [ "$SCENARIO" = "C" ]; then
|
|||
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
|
||||
|
||||
|
|
@ -128,7 +371,7 @@ while [ -z "$OUTCOME" ]; do
|
|||
if [ "$SCENARIO" = "A" ]; then
|
||||
digit "ru/ss_a_day_${TYPE}"
|
||||
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}"
|
||||
DAY=$DIGIT
|
||||
fi
|
||||
|
|
@ -137,11 +380,10 @@ while [ -z "$OUTCOME" ]; do
|
|||
fi
|
||||
save
|
||||
|
||||
if [ "$DAY" = "9" ]; then
|
||||
play "ru/ss_optout"; OUTCOME=opt_out; break
|
||||
elif [ "$DAY" = "0" ]; then
|
||||
play "ru/ss_operator"; OUTCOME=operator; break
|
||||
elif [ "$DAY" != "1" ] && [ "$DAY" != "2" ]; then
|
||||
if [ "$DAY" != "1" ] && [ "$DAY" != "2" ]; 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
|
||||
|
||||
|
|
@ -162,6 +404,9 @@ while [ -z "$OUTCOME" ]; do
|
|||
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
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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} слайдов)")
|
||||
|
|
@ -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> </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()
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
},
|
||||
}];
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -69,36 +69,69 @@ def load_settings(conn):
|
|||
"delay_after_sms_minutes": 90, "retry_interval_minutes": 120,
|
||||
"attempts_per_day": 3, "max_attempts": 5,
|
||||
"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)
|
||||
|
||||
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)
|
||||
no_input_escalate = settings.get("no_input_escalate_after", 2)
|
||||
def resolve_scenario(created_at, now=None):
|
||||
"""Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра),
|
||||
со следующего дня → B (день фиксирован = завтра, только половина дня)."""
|
||||
if not created_at:
|
||||
return "A"
|
||||
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,
|
||||
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
|
||||
WHERE l.order_group_id = og.id) as attempt_count,
|
||||
(SELECT count(*) FROM 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 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
|
||||
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'
|
||||
AND og.autocall_opt_out = false
|
||||
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out')
|
||||
AND og.sms_sent_at IS NOT NULL
|
||||
-- Пауза после SMS прошла
|
||||
AND og.sms_sent_at < NOW() - make_interval(mins => %s)
|
||||
-- Пауза после SMS (или после неудачной попытки) прошла
|
||||
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
|
||||
) 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 (
|
||||
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
|
||||
WHERE l.order_group_id = og.id) < %s
|
||||
ORDER BY og.sms_sent_at ASC
|
||||
""", (delay_min, retry_min, max_attempts))
|
||||
-- Гео-исключения: те же, что у 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 ('*', '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()]
|
||||
|
||||
def is_work_time(settings, now=None):
|
||||
|
|
@ -136,8 +202,226 @@ def normalize_phone(phone):
|
|||
clean = "7" + clean
|
||||
return clean
|
||||
|
||||
def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||
"""Формирует содержимое call-файла."""
|
||||
def get_city_exclusion(conn, city):
|
||||
"""Гео-исключение — общее для 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 = normalize_phone(phone)
|
||||
# Формат набора: 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))
|
||||
if is_test_call:
|
||||
test_mode = True
|
||||
elif manual and group.get("id"):
|
||||
test_mode = False
|
||||
elif test_mode:
|
||||
test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
|
||||
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
|
||||
|
||||
scenario = group.get("scenario", "A") # выбор дня + времени
|
||||
call_type = "dlv"
|
||||
# Сценарий: в день добавления группы → A (завтра/послезавтра),
|
||||
# со следующего дня → 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 = [
|
||||
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: ATTEMPT={attempt_no}",
|
||||
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_KEY={API_KEY}",
|
||||
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)
|
||||
|
||||
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."""
|
||||
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]
|
||||
filename = f"ss-{gid}-{int(time.time())}.call"
|
||||
# Пишем файл на АТС через sshpass + cat
|
||||
|
|
@ -204,7 +524,8 @@ def get_queued_calls(conn):
|
|||
"""Ручные запросы «Позвонить сейчас» из autocall_queue."""
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
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
|
||||
WHERE q.processed_at IS NULL
|
||||
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"])
|
||||
if ids:
|
||||
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
|
||||
""", (ids,))
|
||||
og = {r["id"]: r for r in cur.fetchall()}
|
||||
|
|
@ -224,6 +547,12 @@ def get_queued_calls(conn):
|
|||
if g:
|
||||
r["customer_phone_normalized"] = g.get("customer_phone_normalized")
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -249,6 +578,33 @@ def process_queue(conn, settings):
|
|||
if not phone:
|
||||
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
||||
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:
|
||||
with conn.cursor() as cur:
|
||||
|
|
@ -263,9 +619,18 @@ def process_queue(conn, settings):
|
|||
# Для теста не подставляем customer_phone_normalized (нет заказа)
|
||||
if is_test:
|
||||
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"])
|
||||
pushed += 1
|
||||
else:
|
||||
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
|
||||
# по правилу владельца заявка живёт, пока окно не откроется или
|
||||
# пока её не обработают вручную. Интерфейс в этот момент блокирует
|
||||
# кнопку автодозвона и объясняет причину (autocallAvailability.js).
|
||||
log.info(
|
||||
f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — "
|
||||
"заявка остаётся в очереди до открытия окна"
|
||||
)
|
||||
attempt_map[group_id] = attempt_no + 1
|
||||
return pushed
|
||||
|
||||
|
|
@ -277,10 +642,109 @@ def has_pending_items(conn):
|
|||
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():
|
||||
conn = get_db_conn()
|
||||
try:
|
||||
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: если кампания выключена и очередь пуста — выходим молча.
|
||||
# Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе.
|
||||
|
|
@ -305,6 +769,17 @@ def main():
|
|||
|
||||
pushed = 0
|
||||
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
|
||||
if push_call_file(group, settings, attempt_no):
|
||||
pushed += 1
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ def send_telegram(message, chat_id):
|
|||
|
||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_groups_to_send(conn):
|
||||
def get_groups_to_send(conn, max_attempts):
|
||||
"""Группы, которым ещё не отправили SMS.
|
||||
|
||||
Режимы (из sms_city_profiles):
|
||||
|
|
@ -183,7 +183,7 @@ def get_groups_to_send(conn):
|
|||
cur.execute("""
|
||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||
og.delivery_type, og.town,
|
||||
og.delivery_type, og.town, og.sms_attempts,
|
||||
-- Профиль города (fallback на дефолтные значения если нет записи)
|
||||
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
|
||||
COALESCE(scp.delivery_text, '') as delivery_text,
|
||||
|
|
@ -210,6 +210,16 @@ def get_groups_to_send(conn):
|
|||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
||||
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 часа
|
||||
AND NOT EXISTS (
|
||||
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.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
|
||||
""")
|
||||
""", (max_attempts,))
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
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)
|
||||
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):
|
||||
|
|
@ -309,7 +342,7 @@ def step_send_new(conn, settings):
|
|||
max_attempts = settings.get("max_attempts", 2)
|
||||
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")
|
||||
|
||||
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"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)
|
||||
|
||||
if sms_id:
|
||||
|
|
@ -364,7 +399,7 @@ def step_send_new(conn, settings):
|
|||
sms_text=sms_text,
|
||||
status="sent",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
attempts=attempt_no,
|
||||
)
|
||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
||||
if sms_mode == "notification":
|
||||
|
|
@ -374,6 +409,7 @@ def step_send_new(conn, settings):
|
|||
"sms_sent_at": "NOW()",
|
||||
"first_sms_sent_at": "NOW()",
|
||||
"status": "first_sms_sent",
|
||||
"sms_attempts": attempt_no,
|
||||
})
|
||||
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
|
||||
else:
|
||||
|
|
@ -381,6 +417,7 @@ def step_send_new(conn, settings):
|
|||
update_order_group(conn, group_id, {
|
||||
"notification_status": "sms_sending",
|
||||
"sms_sent_at": "NOW()",
|
||||
"sms_attempts": attempt_no,
|
||||
})
|
||||
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
|
||||
sent_count += 1
|
||||
|
|
@ -394,7 +431,7 @@ def step_send_new(conn, settings):
|
|||
sms_text=sms_text,
|
||||
status="send_failed",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
attempts=attempt_no,
|
||||
error_message=error,
|
||||
)
|
||||
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, {
|
||||
"notification_status": "send_failed",
|
||||
"last_sms_error": error[:200],
|
||||
"sms_attempts": attempt_no,
|
||||
})
|
||||
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"])
|
||||
name = item.get("customer_name") or item.get("group_key", "—")
|
||||
phone = item.get("customer_phone", "")
|
||||
attempts = item.get("attempts", 1)
|
||||
attempts = item.get("attempts") or 1
|
||||
was_needs_check = item.get("needs_check", False)
|
||||
|
||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||
|
|
@ -471,9 +509,11 @@ def step_check_status(conn, settings):
|
|||
})
|
||||
else:
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "send_failed",
|
||||
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
||||
"notification_status": "manual_required",
|
||||
"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)
|
||||
|
||||
elif code in LIMIT_ERROR_CODES:
|
||||
|
|
@ -509,7 +549,7 @@ def step_handle_expired(conn, settings):
|
|||
group_id = str(item["order_group_id"])
|
||||
name = item.get("customer_name") or item.get("group_key", "—")
|
||||
phone = item.get("customer_phone", "")
|
||||
attempts = item.get("attempts", 1)
|
||||
attempts = item.get("attempts") or 1
|
||||
delivery_link = item.get("delivery_link", "")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
|
|
@ -555,8 +652,9 @@ def main():
|
|||
sent = step_send_new(conn, settings)
|
||||
delivered = step_check_status(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:
|
||||
send_telegram(
|
||||
|
|
|
|||
|
|
@ -84,6 +84,22 @@ def get_db_conn():
|
|||
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):
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||
|
|
@ -99,7 +115,8 @@ def load_settings(conn):
|
|||
"test_mode": True,
|
||||
"test_phone": "79788382260",
|
||||
}
|
||||
return dict(row)
|
||||
settings = dict(row)
|
||||
return apply_schedule_work_days(conn, settings)
|
||||
|
||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine)
|
|||
Заменяет n8n workflow "Ручное управление"
|
||||
|
||||
Логика:
|
||||
Если после второй SMS (notification_status = 'second_sms_sent')
|
||||
прошло auto_manual_after_hours и клиент не согласовал
|
||||
Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч)
|
||||
после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и
|
||||
(delivery_status = 'pending_confirmation'):
|
||||
→ notification_status = 'manual_required'
|
||||
→ delivery_status = 'manual_confirmation_required'
|
||||
→ next_notification_check_at = +3 мин
|
||||
→ Telegram-уведомление
|
||||
|
||||
Ожидание считаем только по тем каналам, что реально включены в системе
|
||||
(load_channels):
|
||||
• второе приглашение включено → ждём, пока оно уйдёт;
|
||||
• автодозвон включён и номеру звонить можно → ждём звонок, отсчёт
|
||||
начнётся от него (позвонили позже — ожидание начинается заново);
|
||||
• канал выключен / звонить нельзя (не звонить, гео, исключение) —
|
||||
не ждём его и переводим по сроку ожидания.
|
||||
|
||||
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
||||
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
||||
"""
|
||||
|
|
@ -61,13 +68,30 @@ def get_db_conn():
|
|||
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):
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -94,52 +118,129 @@ def is_within_work_hours(settings):
|
|||
|
||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_groups_to_manual(conn):
|
||||
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
||||
def get_groups_to_manual(conn, channels=None, manual_after_hours=3):
|
||||
"""Группы, где клиент не выбрал дату/время и пора в ручную работу.
|
||||
|
||||
Сценарии:
|
||||
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 без доставки → зависшая группа
|
||||
Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого
|
||||
приглашения: если клиенту позвонили позже, ожидание начинается заново.
|
||||
|
||||
Каналы берём из настроек: ждём только те шаги, которые реально включены.
|
||||
• второе приглашение включено → ждём, пока оно уйдёт;
|
||||
• автодозвон включён и этому номеру звонить можно → ждём звонка;
|
||||
• канал выключен (или звонить нельзя: не звонить/гео/исключение) —
|
||||
не ждём его и переводим в ручную работу по сроку ожидания.
|
||||
|
||||
Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие
|
||||
заказы в системе ведёт только менеджер, автоматика их не трогает.
|
||||
"""
|
||||
channels = channels or {}
|
||||
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,
|
||||
og.notification_status, og.delivery_link,
|
||||
og.first_sms_sent_at, og.second_sms_sent_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
|
||||
JOIN contact c ON c.id = og.id
|
||||
JOIN callable cl ON cl.id = og.id
|
||||
WHERE og.delivery_status = 'pending_confirmation'
|
||||
AND (
|
||||
-- 1. second_sms_sent + срок истёк
|
||||
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
|
||||
-- 2. first_sms_sent + срок истёк (>3h после отправки)
|
||||
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
|
||||
-- Иначе second_campaign не успеет отправить вторую SMS
|
||||
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||
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 og.second_sms_sent_at IS NOT NULL)
|
||||
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
||||
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sms_campaign_log scl
|
||||
WHERE scl.order_group_id = og.id
|
||||
AND scl.status IN ('sent', 'checking')
|
||||
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')
|
||||
-- Клиент уже выбрал дату/время — в ручную не переводим
|
||||
AND og.delivery_date IS NULL
|
||||
-- Контакта не было — отсчитывать не от чего
|
||||
AND c.last_contact_at > TIMESTAMP 'epoch'
|
||||
-- Срок ожидания истёк от последнего контакта
|
||||
AND NOW() - c.last_contact_at >= make_interval(hours => %s)
|
||||
-- Второе приглашение ждём, только если канал включён
|
||||
AND (NOT %s OR og.second_sms_sent_at IS NOT NULL)
|
||||
-- Звонок ждём, только если автодозвон включён и звонить можно
|
||||
AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call)
|
||||
-- Гео-исключения — общие для 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 ('*', 'manual')
|
||||
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||
)
|
||||
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()]
|
||||
|
||||
|
||||
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):
|
||||
with conn.cursor() as cur:
|
||||
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)
|
||||
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)
|
||||
log.info(f"Step 1: {len(groups)} groups to move to manual")
|
||||
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 "
|
||||
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
|
||||
dry_run = os.environ.get("DRY_RUN") == "1"
|
||||
for group in groups:
|
||||
group_id = str(group["id"])
|
||||
name = group.get("customer_name") or group.get("group_key", "—")
|
||||
phone = group.get("customer_phone", "")
|
||||
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, {
|
||||
"notification_status": "manual_required",
|
||||
"delivery_status": "manual_confirmation_required",
|
||||
"sms_attempts": 2,
|
||||
# Попытки НЕ подменяем: показываем столько, сколько реально отправлено
|
||||
"sms_attempts": attempts,
|
||||
"last_sms_error": None,
|
||||
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
||||
# Дальше ждать нечего — проверки по этому заказу закончились
|
||||
"next_notification_check_at": None,
|
||||
"status": "manual_required",
|
||||
})
|
||||
|
||||
send_telegram(
|
||||
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
||||
f"Клиент не согласовал доставку после двух SMS\n"
|
||||
f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта "
|
||||
f"({'звонки' if channels.get('autocall') else 'SMS'})\n"
|
||||
f"Ссылка: {delivery_link}",
|
||||
tg_chat,
|
||||
)
|
||||
|
|
@ -246,8 +367,9 @@ def main():
|
|||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
||||
conn.commit()
|
||||
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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,22 @@ def get_db_conn():
|
|||
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):
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
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,
|
||||
"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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -183,6 +200,15 @@ def get_groups_to_send(conn):
|
|||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||
WHERE og.delivery_status = 'paid_storage'
|
||||
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 og.delivery_link IS NOT NULL
|
||||
AND og.delivery_link != ''
|
||||
|
|
|
|||
|
|
@ -83,6 +83,22 @@ def get_db_conn():
|
|||
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):
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
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,
|
||||
"auto_manual_after_hours": 3,
|
||||
}
|
||||
return dict(row)
|
||||
settings = dict(row)
|
||||
return apply_schedule_work_days(conn, settings)
|
||||
|
||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -190,7 +207,7 @@ def send_telegram(message, chat_id):
|
|||
|
||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_groups_to_send(conn):
|
||||
def get_groups_to_send(conn, max_attempts):
|
||||
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
||||
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
||||
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())
|
||||
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
||||
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 в логе
|
||||
AND NOT EXISTS (
|
||||
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.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
|
||||
""")
|
||||
""", (max_attempts,))
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
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):
|
||||
"""Проверка: сейчас рабочие часы.
|
||||
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 группам, где первая доставлена, но нет согласования"""
|
||||
api_id = settings.get("sms_api_id", SMS_API_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")
|
||||
|
||||
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"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)
|
||||
|
||||
if sms_id:
|
||||
|
|
@ -372,7 +423,7 @@ def step_send_new(conn, settings):
|
|||
sms_text=sms_text,
|
||||
status="sent",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
attempts=attempt_no,
|
||||
)
|
||||
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
||||
update_order_group(conn, group_id, {
|
||||
|
|
@ -390,7 +441,7 @@ def step_send_new(conn, settings):
|
|||
sms_text=sms_text,
|
||||
status="send_failed",
|
||||
sms_code=code,
|
||||
attempts=1,
|
||||
attempts=attempt_no,
|
||||
error_message=error,
|
||||
)
|
||||
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"])
|
||||
name = item.get("customer_name") or item.get("group_key", "—")
|
||||
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)
|
||||
|
||||
|
|
@ -501,7 +552,7 @@ def step_handle_expired(conn, settings):
|
|||
group_id = str(item["order_group_id"])
|
||||
name = item.get("customer_name") or item.get("group_key", "—")
|
||||
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}")
|
||||
update_sms_log(conn, log_id, status="expired",
|
||||
|
|
|
|||
|
|
@ -1,26 +1,70 @@
|
|||
/**
|
||||
* @file CallStatusBadge.jsx
|
||||
* @description Бейдж коммуникационного статуса для строк реестра групп.
|
||||
* Показывает проблему автодозвона снаружи, не открывая карточку.
|
||||
* Приоритет сверху вниз — один бейдж наружу.
|
||||
* @description Бейдж автодозвона в строке реестра доставок.
|
||||
*
|
||||
* Раньше наружу выходили две безликие плашки: «Автодозвон ✅» и «Автодозвон ❌» —
|
||||
* по ним нельзя было понять, не дозвонились, не выбрал клиент день/время или заказ ушёл
|
||||
* менеджеру. Теперь у каждого состояния своя подпись, взятая из
|
||||
* AUTOCALL_STATUS_META (тот же список, что в фильтрах, — не разъезжается).
|
||||
*
|
||||
* «Не звонили» показываем только по заказам, которые ещё ждут решения
|
||||
* (pending_confirmation / manual_confirmation_required): в уже доставленных
|
||||
* заказах такая плашка — просто шум.
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
// Приоритет: отказ > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота
|
||||
export const resolveCallBadge = (g) => {
|
||||
if (!g) return null;
|
||||
import { AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
||||
|
||||
// 1. Отказ от звонков
|
||||
if (g.autocall_opt_out) {
|
||||
return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" };
|
||||
}
|
||||
/** Статусы, при которых заказ ещё ждёт решения клиента. */
|
||||
const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation_required"];
|
||||
|
||||
// 2. Слот от робота (информационный, самый низкий приоритет — показываем если нет проблем)
|
||||
if (g.deliveryDateSource === "autocall") {
|
||||
return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" };
|
||||
}
|
||||
const STYLES = {
|
||||
confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]",
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -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("требует ручного управления");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,8 +23,41 @@ const DEFAULT_SCHEDULE = {
|
|||
deliveryDays: [1, 2, 3, 4, 5],
|
||||
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 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 toggle = (num) => {
|
||||
if (selectedDays.includes(num)) {
|
||||
|
|
@ -79,6 +112,13 @@ export const BusinessSchedulePanel = () => {
|
|||
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
||||
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
||||
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);
|
||||
setOriginal(loaded);
|
||||
|
|
@ -97,7 +137,8 @@ export const BusinessSchedulePanel = () => {
|
|||
const hasChanges =
|
||||
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
||||
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 () => {
|
||||
if (!hasChanges) return;
|
||||
|
|
@ -115,6 +156,13 @@ export const BusinessSchedulePanel = () => {
|
|||
p_delivery_days: schedule.deliveryDays,
|
||||
p_pickup_days: schedule.pickupDays,
|
||||
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 (data?.ok) {
|
||||
|
|
@ -153,6 +201,8 @@ export const BusinessSchedulePanel = () => {
|
|||
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
||||
Дни самовывоза — единый источник правды: их же использует страница клиента по ссылке
|
||||
и робот-автодозвон, в другие дни самовывоз не предлагается.
|
||||
</p>
|
||||
</div>
|
||||
{savedAt && !hasChanges && (
|
||||
|
|
@ -169,7 +219,8 @@ export const BusinessSchedulePanel = () => {
|
|||
Доставка
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
|
||||
Дни, в которые клиент может выбрать доставку по ссылке. В остальные дни доставка
|
||||
не предлагается.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -180,6 +231,26 @@ export const BusinessSchedulePanel = () => {
|
|||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
||||
</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>
|
||||
|
||||
{/* Pickup days */}
|
||||
|
|
@ -191,7 +262,8 @@ export const BusinessSchedulePanel = () => {
|
|||
Самовывоз
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
|
||||
Дни, в которые склад выдаёт самовывоз. В остальные дни самовывоз не предлагается —
|
||||
ни в ссылке клиента, ни в звонке робота.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -202,6 +274,32 @@ export const BusinessSchedulePanel = () => {
|
|||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
||||
</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>
|
||||
|
||||
{/* SMS days */}
|
||||
|
|
@ -213,7 +311,7 @@ export const BusinessSchedulePanel = () => {
|
|||
SMS-приглашения
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
|
||||
Дни, в которые отправляются SMS-приглашения клиентам.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Badge } from "../UI/Badge";
|
|||
import { supabase } from "../../supabaseClient";
|
||||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
||||
import { resolveSmsBalance } from "../../services/smsBalance";
|
||||
|
||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||
const CAMPAIGNS = [
|
||||
|
|
@ -114,6 +115,7 @@ export const SmsCampaignPanel = () => {
|
|||
const [settings, setSettings] = useState(null);
|
||||
const [allSettings, setAllSettings] = useState({});
|
||||
const [campaignCounts, setCampaignCounts] = useState({});
|
||||
const [smsBalance, setSmsBalance] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
|
@ -145,6 +147,9 @@ export const SmsCampaignPanel = () => {
|
|||
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
||||
setAllSettings(settingsMap);
|
||||
|
||||
// Баланс — тот же живой источник, что на главной странице
|
||||
resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal));
|
||||
|
||||
// Load counts per campaign (for cards)
|
||||
const { data: countsData, error: countsErr } = await supabase
|
||||
.from("sms_campaign_log")
|
||||
|
|
@ -317,17 +322,21 @@ export const SmsCampaignPanel = () => {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
||||
{/* Balance banner */}
|
||||
{allSettings.first_sms?.last_balance != null && (
|
||||
{/* Balance banner — тот же живой баланс, что на главной (один источник) */}
|
||||
{smsBalance?.balance != null && (
|
||||
<Panel className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💰</span>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||||
<div className="text-lg font-bold text-[var(--color-text)]">
|
||||
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
||||
{smsBalance.balance.toLocaleString("ru-RU")} ₽
|
||||
</div>
|
||||
{!smsBalance.live && (
|
||||
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||
показан баланс на момент последней рассылки — живой запрос не прошёл
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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)} />
|
||||
</div>
|
||||
|
||||
|
|
@ -804,7 +813,7 @@ export const SmsCampaignPanel = () => {
|
|||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const SettingField = ({ label, value, onChange }) => (
|
||||
const SettingField = ({ label, value, onChange, hint }) => (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||
<input
|
||||
|
|
@ -813,6 +822,7 @@ const SettingField = ({ label, value, onChange }) => (
|
|||
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"
|
||||
/>
|
||||
{hint ? <span className="mt-1 block text-[10px] leading-snug text-[var(--color-text-muted)]">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ const OUTCOME_LABELS = {
|
|||
confirmed_kept_previous: "Правка не доведена",
|
||||
confirmed_silent: "Молчание",
|
||||
no_answer: "Не взял",
|
||||
no_input_day: "Не нажал (день)",
|
||||
no_input_half: "Не нажал (время)",
|
||||
no_input_day: "Не выбрал день",
|
||||
no_input_half: "Не выбрал время",
|
||||
operator: "Менеджер",
|
||||
opt_out: "Отказ",
|
||||
invalid_number: "Неверный номер",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { Badge } from "../../UI/Badge";
|
|||
import { supabase } from "../../../supabaseClient";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { TestCallPanel } from "./TestCallPanel";
|
||||
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
|
||||
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
|
||||
|
||||
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
||||
const CAMPAIGNS = [
|
||||
|
|
@ -25,10 +27,8 @@ const OUTCOME_META = {
|
|||
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
||||
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
||||
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
||||
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
|
||||
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
|
||||
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
|
||||
opt_out: { 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: "⚠️" },
|
||||
};
|
||||
|
|
@ -64,8 +64,8 @@ const slotText = (l) => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const SettingField = ({ label, value, onChange }) => (
|
||||
<label className="block">
|
||||
const SettingField = ({ label, value, onChange, hint }) => (
|
||||
<label className="block" title={hint || undefined}>
|
||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -284,14 +284,40 @@ export const AutocallChannelTab = () => {
|
|||
)}
|
||||
</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="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.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="«Не нажал» ×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)} />
|
||||
</div>
|
||||
|
||||
|
|
@ -346,6 +372,12 @@ export const AutocallChannelTab = () => {
|
|||
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
|
||||
{isMegaAdmin && <TestCallPanel />}
|
||||
|
||||
{/* ── Локации: где звонит робот ──────────────────────────────────── */}
|
||||
<AutocallLocationsPanel />
|
||||
|
||||
{/* ── Исключения автодозвона ─────────────────────────────────────── */}
|
||||
<AutocallExclusionsPanel />
|
||||
|
||||
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
||||
<Panel className="p-4">
|
||||
<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)]">
|
||||
{fmtPhone(l.customer_phone)}
|
||||
{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 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -18,10 +18,8 @@ const OUTCOME_META = {
|
|||
confirmed_kept_previous: { label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
||||
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
||||
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
||||
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
|
||||
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
|
||||
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
|
||||
opt_out: { 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: "⚠️" },
|
||||
};
|
||||
|
|
@ -49,6 +47,7 @@ const fmtTime = (ts) => {
|
|||
|
||||
export const TestCallPanel = () => {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [callType, setCallType] = useState("dlv"); // dlv = доставка, pkp = самовывоз
|
||||
const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error
|
||||
const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста
|
||||
const [startedAt, setStartedAt] = useState(null);
|
||||
|
|
@ -90,6 +89,7 @@ export const TestCallPanel = () => {
|
|||
order_group_id: null,
|
||||
phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)),
|
||||
scenario: "A",
|
||||
call_type: callType,
|
||||
is_test_call: true,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
|
@ -152,6 +152,29 @@ export const TestCallPanel = () => {
|
|||
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
|
||||
</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">
|
||||
<input
|
||||
type="tel"
|
||||
|
|
@ -227,6 +250,7 @@ export const TestCallPanel = () => {
|
|||
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]">
|
||||
<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>
|
||||
{slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>}
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span>
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
if (!dateKey) return false;
|
||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||
|
|
@ -53,64 +73,67 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
|||
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 = [];
|
||||
|
||||
if (isTodayWorkday) {
|
||||
if (hour < 12) {
|
||||
slots.push({
|
||||
id: `pickup-${todayKey}-first`,
|
||||
date: todayKey,
|
||||
time: "Первая половина дня",
|
||||
label: "Сегодня",
|
||||
pickupType: "today",
|
||||
});
|
||||
}
|
||||
const addSlot = (dateKey, half, label, pickupType) => {
|
||||
slots.push({
|
||||
id: `pickup-${todayKey}-second`,
|
||||
date: todayKey,
|
||||
time: "Вторая половина дня",
|
||||
label: "Сегодня",
|
||||
pickupType: "today",
|
||||
id: `pickup-${dateKey}-${half}`,
|
||||
date: dateKey,
|
||||
time: half === "first" ? "Первая половина дня" : "Вторая половина дня",
|
||||
label,
|
||||
pickupType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const tomorrow = addDaysKey(todayKey, 1);
|
||||
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
||||
slots.push({
|
||||
id: `pickup-${tomorrowWorkday}-first`,
|
||||
date: tomorrowWorkday,
|
||||
time: "Первая половина дня",
|
||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
||||
pickupType: "tomorrow",
|
||||
});
|
||||
slots.push({
|
||||
id: `pickup-${tomorrowWorkday}-second`,
|
||||
date: tomorrowWorkday,
|
||||
time: "Вторая половина дня",
|
||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
||||
pickupType: "tomorrow",
|
||||
});
|
||||
|
||||
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",
|
||||
allowedKeys.forEach((dateKey, index) => {
|
||||
const isToday = dateKey === todayKey;
|
||||
const label = isToday
|
||||
? "Сегодня"
|
||||
: (getDeliveryRelativeDayLabel(dateKey, now) || `День ${index + 1}`);
|
||||
if (isToday) {
|
||||
// После отсечки «сегодня» склад не успеет собрать заказ — вариант не показываем,
|
||||
// первая половина доступна только до границы половины дня.
|
||||
if (nowMinutes >= cutoffMinutes) return;
|
||||
if (nowMinutes < halfEndMinutes) addSlot(dateKey, "first", label, "today");
|
||||
addSlot(dateKey, "second", label, "today");
|
||||
return;
|
||||
}
|
||||
addSlot(dateKey, "first", label, PICKUP_TYPES[index] || "dayAfter");
|
||||
addSlot(dateKey, "second", label, PICKUP_TYPES[index] || "dayAfter");
|
||||
});
|
||||
|
||||
return slots;
|
||||
|
|
@ -156,8 +179,14 @@ export const PickupSlotsPicker = ({
|
|||
selectedSlotId,
|
||||
referenceDate = new Date(),
|
||||
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) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,7 @@ import { Panel } from "../UI/Panel";
|
|||
import { SkeletonPage } from "../UI/Loading";
|
||||
import { Pagination } from "../UI/Pagination";
|
||||
import { OrderFilters } from "../orders/OrderFilters";
|
||||
import { SmsProblemNotice } from "./SmsProblemNotice";
|
||||
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||
|
||||
|
|
@ -299,6 +300,7 @@ const isFilterActive = (filters) => {
|
|||
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
|
||||
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "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.dateTo) return true;
|
||||
return false;
|
||||
|
|
@ -404,7 +406,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
|
|||
// Reset to page 1 when filters change
|
||||
React.useEffect(() => {
|
||||
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 totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));
|
||||
|
|
@ -508,6 +510,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
|
|||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Причина, по которой SMS не уходят — до таблицы, чтобы не искать её в логе */}
|
||||
<SmsProblemNotice orderGroupIds={filteredGroups.map((g) => g.id)} />
|
||||
|
||||
{!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)]">
|
||||
По этому поиску ничего не найдено.
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,19 +3,20 @@
|
|||
* @description Блок «Автодозвон» в карточке заказа.
|
||||
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Badge } from "../UI/Badge";
|
||||
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 = {
|
||||
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
||||
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
||||
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
||||
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
||||
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
|
||||
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
|
||||
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
|
||||
opt_out: { 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: "⚠️" },
|
||||
};
|
||||
|
|
@ -43,13 +44,16 @@ const slotText = (l) => {
|
|||
|
||||
export const AutocallStatusCard = ({ order, userRole }) => {
|
||||
const [calls, setCalls] = useState([]);
|
||||
const [queueItem, setQueueItem] = useState(null);
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [loading, setLoading] = 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 optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
|
||||
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
|
||||
const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null;
|
||||
|
||||
const loadCalls = useCallback(async () => {
|
||||
if (!order?.id) return;
|
||||
|
|
@ -60,7 +64,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
.select("*")
|
||||
.eq("order_group_id", order.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(5);
|
||||
.limit(10);
|
||||
if (error) throw error;
|
||||
setCalls(data || []);
|
||||
} catch (e) {
|
||||
|
|
@ -70,23 +74,147 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
}
|
||||
}, [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 () => {
|
||||
if (!order?.id) return;
|
||||
setQueued(true);
|
||||
setSending(true);
|
||||
try {
|
||||
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn)
|
||||
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn).
|
||||
// Сценарий планировщик определит сам по дню добавления группы
|
||||
// (день добавления → A: завтра/послезавтра; позже → B: только завтра).
|
||||
const { error } = await supabase.from("autocall_queue").insert({
|
||||
order_group_id: order.id,
|
||||
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;
|
||||
setTimeout(() => setQueued(false), 3000);
|
||||
await loadQueue();
|
||||
} catch (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 lastSlot = lastCall ? slotText(lastCall) : null;
|
||||
|
||||
// Единый человеческий статус (по аналогии со статусом SMS)
|
||||
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
|
||||
const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
|
||||
</div>
|
||||
|
||||
{/* Итог */}
|
||||
{/* Единый статус — как у SMS */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{optOut ? (
|
||||
<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>
|
||||
)}
|
||||
<Badge tone={statusMeta.tone}>{statusMeta.label}</Badge>
|
||||
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
|
||||
</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 ? (
|
||||
<div className="space-y-1.5">
|
||||
{calls.map((c) => {
|
||||
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
||||
const slot = slotText(c);
|
||||
return (
|
||||
<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]">
|
||||
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
|
||||
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
|
||||
{slot && <span className="text-[var(--color-text)]">{slot}</span>}
|
||||
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1 border-t border-[var(--color-border)] pt-3">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div>
|
||||
{loading && <div className="mb-1 text-[10px] text-[var(--color-text-muted)]">обновляем…</div>}
|
||||
<div className="space-y-1.5">
|
||||
{calls.map((c, idx) => {
|
||||
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
||||
const slot = slotText(c);
|
||||
const attemptNo = calls.length - idx;
|
||||
return (
|
||||
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
|
||||
<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 className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
|
||||
|
|
@ -161,23 +350,48 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
|||
|
||||
{/* Кнопки */}
|
||||
{canManage && (
|
||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCallNow}
|
||||
disabled={queued || optOut}
|
||||
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"
|
||||
>
|
||||
{queued ? "✓ В очереди" : "📞 Позвонить сейчас"}
|
||||
</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 className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||
{autocallBlocked && !orderAgreed && (
|
||||
<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)]">
|
||||
🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами.
|
||||
</div>
|
||||
)}
|
||||
{orderAgreed && (
|
||||
<div className="mb-2 rounded-lg bg-[var(--color-surface-strong)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-muted)]">
|
||||
Заказ уже согласован — автодозвон не нужен. Если надо переспросить клиента, позвоните сами.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCallNow}
|
||||
disabled={sending || processing || optOut || autocallBlocked || orderAgreed}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -689,11 +689,21 @@ export const OrderDetailPanel = ({
|
|||
// ---- Drag-and-drop block reordering state ----
|
||||
const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole);
|
||||
const [blockOrder, setBlockOrder] = React.useState(() => {
|
||||
let saved = null;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
|
||||
if (Array.isArray(saved) && saved.length > 0) return saved;
|
||||
const parsed = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
|
||||
if (Array.isArray(parsed) && parsed.length > 0) saved = parsed;
|
||||
} 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(() => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import React from "react";
|
|||
import { Button } from "../UI/Button";
|
||||
import { Input } from "../UI/Input";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { DatePicker } from "../UI/DatePicker";
|
||||
import { DateRangePicker } from "../UI/DateRangePicker";
|
||||
import { Switch } from "../UI/Switch";
|
||||
import { AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
|
||||
|
||||
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
|
@ -121,10 +122,17 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
|||
{ value: "pickup", label: "Самовывоз" },
|
||||
];
|
||||
|
||||
const smsProblemOptions = [
|
||||
{ value: "all", label: "Проблемы SMS" },
|
||||
{ value: "problem_not_sent", label: "SMS не отправлено" },
|
||||
{ value: "problem_not_delivered", label: "SMS не доставлено" },
|
||||
const smsStatusOptions = [
|
||||
{ value: "all", label: "Статус SMS" },
|
||||
{ value: "not_sent", label: "Не отправлено" },
|
||||
{ value: "sent", label: "Отправлено" },
|
||||
{ value: "delivered", label: "Доставлено" },
|
||||
{ value: "error", label: "Ошибка" },
|
||||
];
|
||||
|
||||
const autocallStatusOptions = [
|
||||
{ value: "all", label: "Статус автодозвона" },
|
||||
...AUTOCALL_STATUS_OPTIONS,
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -205,29 +213,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date range + SMS problems (width matches its text) + 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-end gap-3">
|
||||
<DatePicker
|
||||
value={filters.dateFrom || ""}
|
||||
onChange={(v) => updateFilter("dateFrom", v)}
|
||||
placeholder="С даты"
|
||||
label="Период: с"
|
||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
||||
/>
|
||||
<DatePicker
|
||||
value={filters.dateTo || ""}
|
||||
onChange={(v) => updateFilter("dateTo", v)}
|
||||
placeholder="По дату"
|
||||
label="по"
|
||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
||||
{/* Row 2: Период + Проблемы SMS + Прозвонить + hide-completed toggle.
|
||||
Выравнивание по верхней линии полей ввода. */}
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<DateRangePicker
|
||||
valueFrom={filters.dateFrom || ""}
|
||||
valueTo={filters.dateTo || ""}
|
||||
onChange={(from, to) => {
|
||||
setFilters((current) => ({ ...current, dateFrom: from, dateTo: to }));
|
||||
}}
|
||||
className="min-w-[180px] flex-1 md:max-w-[240px]"
|
||||
/>
|
||||
{hasDateFilter && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
|
@ -235,9 +236,16 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
|||
|
||||
<DropdownSelect
|
||||
value={filters.smsStatus || "all"}
|
||||
options={smsProblemOptions}
|
||||
options={smsStatusOptions}
|
||||
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" && (
|
||||
|
|
|
|||
|
|
@ -103,6 +103,30 @@ const SMS_STATUS_LABELS = {
|
|||
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) => {
|
||||
if (!ts) return "—";
|
||||
try {
|
||||
|
|
@ -172,7 +196,7 @@ const STAGES_PICKUP = [
|
|||
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
||||
];
|
||||
|
||||
const getActiveStageIndex = (order, smsLogs = []) => {
|
||||
const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
|
||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
||||
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
||||
|
|
@ -197,6 +221,12 @@ const getActiveStageIndex = (order, smsLogs = []) => {
|
|||
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
|
||||
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
|
||||
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
|
||||
|
|
@ -206,10 +236,10 @@ const getActiveStageIndex = (order, smsLogs = []) => {
|
|||
return 0;
|
||||
};
|
||||
|
||||
const ProgressStepper = ({ order, smsLogs = [] }) => {
|
||||
const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
|
||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
||||
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
||||
const activeIdx = getActiveStageIndex(order, smsLogs);
|
||||
const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs);
|
||||
|
||||
if (activeIdx === -1) {
|
||||
return (
|
||||
|
|
@ -276,7 +306,7 @@ const ProgressStepper = ({ order, smsLogs = [] }) => {
|
|||
};
|
||||
|
||||
// ─── 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 deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
||||
|
|
@ -350,6 +380,26 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
|||
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)
|
||||
if (!isPickup) {
|
||||
if (driverName) {
|
||||
|
|
@ -394,7 +444,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
|||
<span className="text-base flex-shrink-0">{item.icon}</span>
|
||||
<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-sm font-semibold truncate ${textStyles[item.tone] || textStyles.muted}`}>
|
||||
<p className={`text-sm font-semibold break-words ${textStyles[item.tone] || textStyles.muted}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -408,6 +458,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
|||
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||
const [history, setHistory] = useState(null);
|
||||
const [smsLogs, setSmsLogs] = useState([]);
|
||||
const [autocallLogs, setAutocallLogs] = useState([]);
|
||||
const [pushLogs, setPushLogs] = useState([]);
|
||||
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
||||
const [cityProfile, setCityProfile] = useState(null);
|
||||
|
|
@ -440,6 +491,18 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
|||
if (!smsError) setSmsLogs(smsData || []);
|
||||
} 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.
|
||||
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
|
||||
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) {
|
||||
for (const push of pushLogs) {
|
||||
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));
|
||||
return events;
|
||||
}, [history, smsLogs, pushLogs, invitation, cityProfile]);
|
||||
}, [history, smsLogs, autocallLogs, pushLogs, invitation, cityProfile]);
|
||||
|
||||
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
|
||||
const latestPush = pushLogs[0] || null;
|
||||
|
|
@ -618,8 +701,8 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProgressStepper order={order} smsLogs={smsLogs} />
|
||||
<StatusSummary order={order} smsLogs={smsLogs} />
|
||||
<ProgressStepper order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
|
||||
<StatusSummary order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
|
||||
<div className={`rounded-xl border px-3 py-2.5 ${
|
||||
activePushSubscriptions.length > 0
|
||||
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
|
||||
|
|
@ -693,6 +776,34 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
|||
</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" && (
|
||||
<p className={`mt-0.5 text-xs ${
|
||||
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { supabase } from "../../supabaseClient";
|
||||
import { getSmsFailureReason, isSmsFundsError } from "../../services/orderGroupViews";
|
||||
|
||||
// ── Status labels ────────────────────────────────────────────────────────────
|
||||
const NOTIF_LABELS = {
|
||||
|
|
@ -209,6 +210,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
const hasSecondSms = isDelivered(secondSmsLog);
|
||||
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.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 (
|
||||
<div className="p-4">
|
||||
|
|
@ -226,11 +234,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{hasFirstSms ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
||||
) : 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 ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
||||
) : notifStatus === "link_ready" ? (
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -245,7 +255,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{hasSecondSms ? (
|
||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
||||
) : 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 ? (
|
||||
<div className="text-[var(--color-text-muted)]">
|
||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||
|
|
@ -267,7 +277,24 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
{/* Error */}
|
||||
{lastError && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const useOrderGroups = ({
|
|||
}
|
||||
setLoadError("");
|
||||
|
||||
if (!enabled) { setOrderGroups([]); setIsLoading(false); return; }
|
||||
if (!enabled) { setIsLoading(false); return; }
|
||||
|
||||
// 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).
|
||||
|
|
@ -80,12 +80,17 @@ export const useOrderGroups = ({
|
|||
|
||||
if (groupsResult.error) {
|
||||
setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки");
|
||||
setOrderGroups([]);
|
||||
// Список не стираем — показываем прежние данные, а не пустой экран.
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = groupsResult.data || {};
|
||||
// Неожиданная форма ответа — тоже не стираем список.
|
||||
if (!Array.isArray(payload.data)) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setOrderGroups(payload.data || []);
|
||||
setTotalGroups(payload.total ?? (payload.data?.length || 0));
|
||||
setIsLoading(false);
|
||||
|
|
@ -110,7 +115,7 @@ export const useOrderGroups = ({
|
|||
}
|
||||
if (cancelled) return;
|
||||
};
|
||||
if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); }
|
||||
if (enabled) doLoad(); else { setIsLoading(false); }
|
||||
return () => { cancelled = true; };
|
||||
}, [loadLiveData, enabled]);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Skeleton } from "../components/UI/Loading";
|
|||
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
||||
import {
|
||||
confirmDeliveryChoice,
|
||||
fetchBusinessSchedule,
|
||||
fetchDeliveryInvitation,
|
||||
} from "../services/deliveryInvitationApi";
|
||||
|
||||
|
|
@ -127,30 +128,52 @@ const isSundayKey = (dateKey) => {
|
|||
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);
|
||||
while (isSundayKey(next)) {
|
||||
let guard = 0;
|
||||
while (!isAllowedDeliveryDayKey(next, workDays) && guard < 14) {
|
||||
next = addDaysToDateKey(next, 1);
|
||||
guard += 1;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date(), workDays = DEFAULT_DELIVERY_DAYS) => {
|
||||
const todayKey = getBusinessTodayKey(referenceDate);
|
||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||
const firstWorkday = getNextWorkdayKey(todayKey, workDays);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday, workDays);
|
||||
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) {
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -160,10 +183,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
const deliveryTime = invitation.deliveryTime;
|
||||
|
||||
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 firstWorkday = getNextWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||
const firstWorkday = getNextWorkdayKey(todayKey, workDays);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday, workDays);
|
||||
return [
|
||||
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||
|
|
@ -193,7 +216,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
|| deliveryDate
|
||||
|| "";
|
||||
|
||||
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) {
|
||||
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate, workDays)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +240,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) {
|
||||
if (!isAllowedDeliverySlotDate(slotDate, referenceDate, workDays)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -305,6 +328,28 @@ export const ClientDeliveryPage = () => {
|
|||
[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(() => {
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -350,7 +395,7 @@ export const ClientDeliveryPage = () => {
|
|||
};
|
||||
}, [token]);
|
||||
|
||||
const slots = groupSlotsFromInvitation(invitation, referenceDate);
|
||||
const slots = groupSlotsFromInvitation(invitation, referenceDate, deliveryWorkDays);
|
||||
|
||||
const invitationState = invitation?.state || "awaiting_choice";
|
||||
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
||||
|
|
@ -397,7 +442,7 @@ export const ClientDeliveryPage = () => {
|
|||
setSelectedSlot(
|
||||
buildSelectedSlotFromInvitation(
|
||||
loadedInvitation,
|
||||
groupSlotsFromInvitation(loadedInvitation, referenceDate),
|
||||
groupSlotsFromInvitation(loadedInvitation, referenceDate, deliveryWorkDays),
|
||||
) || effectiveSelectedSlot,
|
||||
);
|
||||
setChoiceSaved(true);
|
||||
|
|
@ -642,6 +687,8 @@ export const ClientDeliveryPage = () => {
|
|||
onSelectSlot={handleSlotSelect}
|
||||
selectedSlotId={selectedSlotId}
|
||||
referenceDate={referenceDate}
|
||||
pickupDays={pickupWorkDays}
|
||||
schedule={pickupSchedule}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ describe("ClientDeliveryPage helpers", () => {
|
|||
searchDate: "2026-04-14",
|
||||
}),
|
||||
).toEqual({
|
||||
deliveryType: "delivery",
|
||||
deliveryDate: "2026-04-16",
|
||||
deliveryTime: "После обеда",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
@ -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";
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -183,6 +183,61 @@ export const __resetLocalDeliveryInvitationCache = () => {
|
|||
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) => {
|
||||
if (!token) {
|
||||
throw new Error("Token is required");
|
||||
|
|
|
|||
|
|
@ -245,22 +245,176 @@ export const parseGroupDate = (value) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* SMS delivery problem tone used for both table highlighting and filtering.
|
||||
* Returns "ok" | "not_sent" | "not_delivered".
|
||||
* SMS status used for both table highlighting and filtering.
|
||||
* Returns "not_sent" | "sent" | "delivered" | "error".
|
||||
*/
|
||||
export const getSmsProblemTone = (group) => {
|
||||
if (!group) return "ok";
|
||||
export const getSmsStatus = (group) => {
|
||||
if (!group) return "not_sent";
|
||||
const firstSms = group.firstSmsSentAt || group.first_sms_sent_at;
|
||||
const secondSms = group.secondSmsSentAt || group.second_sms_sent_at;
|
||||
const lastError = group.lastSmsError;
|
||||
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → problem with delivery.
|
||||
if ((firstSms || secondSms) && lastError) return "not_delivered";
|
||||
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → error.
|
||||
if ((firstSms || secondSms) && lastError) return "error";
|
||||
// SMS should be expected but not sent at all yet.
|
||||
if (!firstSms && !secondSms) return "not_sent";
|
||||
// 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";
|
||||
};
|
||||
|
||||
// ─── Статус 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 = {}) => {
|
||||
const query = normalizeDate(filters.query).trim().toLowerCase();
|
||||
const status = filters.status || "all";
|
||||
|
|
@ -270,6 +424,7 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
|||
const dateTo = normalizeDate(filters.dateTo);
|
||||
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
||||
const smsStatus = filters.smsStatus || "all";
|
||||
const autocallStatus = filters.autocallStatus || "all";
|
||||
|
||||
const isWithinDateRange = (group) => {
|
||||
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
||||
|
|
@ -362,9 +517,20 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
|||
}
|
||||
|
||||
if (smsStatus !== "all") {
|
||||
const tone = getSmsProblemTone(group);
|
||||
if (smsStatus === "problem_not_sent" && tone !== "not_sent") return false;
|
||||
if (smsStatus === "problem_not_delivered" && tone !== "not_delivered") return false;
|
||||
const status = getSmsStatus(group);
|
||||
if (smsStatus === "not_sent" && status !== "not_sent") 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) {
|
||||
|
|
@ -397,7 +563,7 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
|||
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
||||
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
||||
{ 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_to_launch", label: "Готово к запуску" },
|
||||
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -231,6 +231,12 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
|||
deliveryDate,
|
||||
deliveryTime,
|
||||
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,
|
||||
pickupDate: row.pickup_date || 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)`;
|
||||
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.
|
||||
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
||||
|
|
@ -610,7 +616,7 @@ export const fetchOrderGroups = async ({
|
|||
|
||||
if (!forceRefresh) {
|
||||
const cached = orderGroupsCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (cached) return { data: cached, error: null };
|
||||
}
|
||||
|
||||
return safeSupabaseCall(async () => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const OUTCOMES = new Set([
|
|||
"no_input_day",
|
||||
"no_input_half",
|
||||
"operator",
|
||||
"opt_out",
|
||||
"invalid_number",
|
||||
"error",
|
||||
]);
|
||||
|
|
@ -29,6 +28,8 @@ type AutocallBody = {
|
|||
customer_phone?: string;
|
||||
attempt_no?: number;
|
||||
scenario?: string;
|
||||
call_type?: string;
|
||||
chosen_date?: string;
|
||||
call_started_at?: string;
|
||||
answered_at?: string | null;
|
||||
duration_sec?: number | null;
|
||||
|
|
@ -50,6 +51,16 @@ function addDays(date: Date, days: number): string {
|
|||
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) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
const cors = getCorsHeaders(request, "private");
|
||||
|
|
@ -81,6 +92,7 @@ Deno.serve(async (request) => {
|
|||
const campaignType = body.campaign_type || "autocall_slot";
|
||||
const attemptNo = body.attempt_no ?? 1;
|
||||
const scenario = body.scenario || "A";
|
||||
const callType = body.call_type === "pkp" ? "pkp" : "dlv";
|
||||
const wasTest = body.was_test_mode ?? false;
|
||||
|
||||
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
|
||||
|
|
@ -109,6 +121,7 @@ Deno.serve(async (request) => {
|
|||
customer_phone: body.customer_phone,
|
||||
attempt_no: attemptNo,
|
||||
scenario,
|
||||
call_type: callType,
|
||||
call_started_at: body.call_started_at || new Date().toISOString(),
|
||||
answered_at: body.answered_at || null,
|
||||
duration_sec: body.duration_sec ?? null,
|
||||
|
|
@ -148,32 +161,41 @@ Deno.serve(async (request) => {
|
|||
|
||||
// ── confirmed*: записываем слот ──
|
||||
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
|
||||
const dayOffset = body.dtmf_day === 2 ? 2 : 1;
|
||||
const deliveryDate = addDays(new Date(), dayOffset);
|
||||
// День называет сам диалог (chosen_date) — робот перечисляет конкретные
|
||||
// дни окна самовывоза, поэтому «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 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 update: Record<string, unknown> = {
|
||||
delivery_date_source: "autocall",
|
||||
delivery_status: "agreed",
|
||||
notification_status: "confirmed",
|
||||
delivery_date: deliveryDate,
|
||||
called: true,
|
||||
called_at: new Date().toISOString(),
|
||||
called_comment: comment,
|
||||
next_notification_check_at: null,
|
||||
};
|
||||
if (timeSlot) update.delivery_time = timeSlot;
|
||||
if (isPickup) {
|
||||
update.pickup_date = deliveryDate;
|
||||
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);
|
||||
}
|
||||
// ── Эскалация ──
|
||||
else if (["operator", "opt_out", "invalid_number"].includes(body.outcome)) {
|
||||
else if (["operator", "invalid_number"].includes(body.outcome)) {
|
||||
const update: Record<string, unknown> = {
|
||||
notification_status: "manual_required",
|
||||
called: true,
|
||||
|
|
@ -181,14 +203,14 @@ Deno.serve(async (request) => {
|
|||
called_comment:
|
||||
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);
|
||||
}
|
||||
// ── no_answer / no_input: решаем, эскалировать ли ──
|
||||
// Пороги берём из настроек кампании (админка → Автодозвон): раньше здесь
|
||||
// стояли константы 2 и 5, поэтому поля «Не нажал ×N → менеджер» и
|
||||
// «Всего попыток» в интерфейсе ни на что не влияли.
|
||||
else {
|
||||
const { count: noInputCount } = await supabase
|
||||
.from("autocall_log")
|
||||
|
|
@ -200,8 +222,14 @@ Deno.serve(async (request) => {
|
|||
.select("id", { count: "exact", head: true })
|
||||
.eq("order_group_id", orderGroupId);
|
||||
|
||||
const noInputEscalate = 2; // из настроек, но пока константа
|
||||
const maxAttempts = 5; // из настроек, но пока константа
|
||||
const { data: settings } = await supabase
|
||||
.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;
|
||||
if (escalate) {
|
||||
await supabase.from("order_groups").update({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue