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 грузится
|
; Это чинит ночной 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"; }
|
say () { echo "$1"; read -r resp; echo "$(date +%T) $1 -> $resp" >> "$LOG"; RESP="$resp"; }
|
||||||
digit () { # $1=озвучка, $2=таймаут мс -> DIGIT
|
digit () { # $1=озвучка, $2=таймаут мс -> DIGIT
|
||||||
say "GET DATA $1 ${2:-9000} 1"
|
say "GET DATA $1 ${2:-9000} 1"
|
||||||
DIGIT=$(echo "$RESP" | sed -n 's/.*result=\([0-9]*\).*/\1/p')
|
# GET DATA отдаёт саму цифру (200 result=1). Тишина приходит как «(timeout)»,
|
||||||
|
# а нажатый «0» — как result=0: раньше их путали и «0» (позвать менеджера)
|
||||||
|
# был неотличим от молчания.
|
||||||
|
case "$RESP" in
|
||||||
|
*"(timeout)"*|*"result=-1"*) DIGIT="" ;;
|
||||||
|
*) DIGIT=$(echo "$RESP" | sed -n 's/^200 result=\([0-9]*\)$/\1/p') ;;
|
||||||
|
esac
|
||||||
|
[ -n "$DIGIT" ] && ANY_DIGIT=1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_digit () { # $1=таймаут мс -> DIGIT (после EXEC Playback; 0 у Asterisk = тишина)
|
||||||
|
say "WAIT FOR DIGIT ${1:-9000}"
|
||||||
|
# WAIT FOR DIGIT возвращает ASCII-код нажатой цифры (49 = «1»), 0 = тишина,
|
||||||
|
# -1 = обрыв. Раньше код сравнивал ASCII с «1»/«2», поэтому меню из фрагментов
|
||||||
|
# не принимало ни одного нажатия — превращаем код в символ.
|
||||||
|
RAW=$(echo "$RESP" | sed -n 's/^200 result=\(-\{0,1\}[0-9]*\)$/\1/p')
|
||||||
|
if [ -z "$RAW" ] || [ "$RAW" -le 0 ]; then
|
||||||
|
DIGIT=""
|
||||||
|
else
|
||||||
|
DIGIT=$(printf "\\$(printf '%03o' "$RAW")")
|
||||||
|
ANY_DIGIT=1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
# Чтение channel-переменной из call-файла (Setvar) через AGI.
|
# Чтение channel-переменной из call-файла (Setvar) через AGI.
|
||||||
getvar () { # $1=имя -> VAR
|
getvar () { # $1=имя -> VAR
|
||||||
|
|
@ -36,6 +57,26 @@ getvar () { # $1=имя -> VAR
|
||||||
}
|
}
|
||||||
play () { say "STREAM FILE $1 \"\""; }
|
play () { say "STREAM FILE $1 \"\""; }
|
||||||
|
|
||||||
|
# Обрыв: клиент положил трубку посередине диалога. Asterisk шлёт SIGHUP и убивает
|
||||||
|
# скрипт — без этого обработчика результат не уходил в БД, и звонок задним числом
|
||||||
|
# записывался как «не взял трубку», хотя человек ответил. Сохраняем то, что успели.
|
||||||
|
on_interrupt () {
|
||||||
|
if [ -z "$OUTCOME" ]; then
|
||||||
|
if [ -n "$HALF" ]; then
|
||||||
|
OUTCOME=no_input_half # день выбрал, время не назвал
|
||||||
|
elif [ -n "$DAY" ] && [ "$DAY" != "0" ]; then
|
||||||
|
OUTCOME=no_input_half # день выбран, до времени не дошло
|
||||||
|
elif [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
|
OUTCOME=no_answer # объявление оператора: человека не было
|
||||||
|
else
|
||||||
|
OUTCOME=no_input_day # ответил, но ничего не нажал
|
||||||
|
fi
|
||||||
|
save
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap on_interrupt HUP TERM INT
|
||||||
|
|
||||||
# Channel-переменные из call-файла (Setvar). Читаем ДО диалога, т.к. AGI
|
# Channel-переменные из call-файла (Setvar). Читаем ДО диалога, т.к. AGI
|
||||||
# не видит их как bash-переменные — только через GET VARIABLE.
|
# не видит их как bash-переменные — только через GET VARIABLE.
|
||||||
getvar API_URL; API_URL=$VAR
|
getvar API_URL; API_URL=$VAR
|
||||||
|
|
@ -44,6 +85,16 @@ getvar GROUP_ID; GROUP_ID=$VAR
|
||||||
getvar ATTEMPT; ATTEMPT=$VAR
|
getvar ATTEMPT; ATTEMPT=$VAR
|
||||||
getvar TEST_MODE; TEST_MODE=$VAR
|
getvar TEST_MODE; TEST_MODE=$VAR
|
||||||
getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR
|
getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR
|
||||||
|
# Даты самовывоза (заполняет планировщик по окну «готовность + 0..2 дня»)
|
||||||
|
getvar PICKUP_DAY1; PICKUP_DAY1=$VAR
|
||||||
|
getvar PICKUP_DAY2; PICKUP_DAY2=$VAR
|
||||||
|
getvar PICKUP_DAY3; PICKUP_DAY3=$VAR
|
||||||
|
getvar PICKUP_DAY1_FIRST; PICKUP_DAY1_FIRST=$VAR
|
||||||
|
# Даты доставки (планировщик берёт рабочие дни графика доставки из админки:
|
||||||
|
# не в день готовности и без выходных — готов в пятницу → понедельник и вторник)
|
||||||
|
getvar DELIVERY_DAY1; DELIVERY_DAY1=$VAR
|
||||||
|
getvar DELIVERY_DAY2; DELIVERY_DAY2=$VAR
|
||||||
|
CHOSEN_DATE=""
|
||||||
|
|
||||||
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
|
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
|
||||||
# выбор всё равно сохранён (AGI умирает молча, финальной записи не будет).
|
# выбор всё равно сохранён (AGI умирает молча, финальной записи не будет).
|
||||||
|
|
@ -63,7 +114,7 @@ save () {
|
||||||
# Отправляем в БД сразу при финальном исходе (клиент может бросить трубку
|
# Отправляем в БД сразу при финальном исходе (клиент может бросить трубку
|
||||||
# в любой момент — результат должен успеть уйти). Идемпотентность по uniqueid.
|
# в любой момент — результат должен успеть уйти). Идемпотентность по uniqueid.
|
||||||
case "$OUTCOME" in
|
case "$OUTCOME" in
|
||||||
confirmed|confirmed_kept_previous|confirmed_silent|no_answer|no_input_day|no_input_half|operator|opt_out|invalid_number|error)
|
confirmed|confirmed_kept_previous|confirmed_silent|no_answer|no_input_day|no_input_half|operator|invalid_number|error)
|
||||||
send_result ;;
|
send_result ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +134,8 @@ send_result () {
|
||||||
\"customer_phone\":\"${CUSTOMER_PHONE:-}\",
|
\"customer_phone\":\"${CUSTOMER_PHONE:-}\",
|
||||||
\"attempt_no\":${ATTEMPT:-1},
|
\"attempt_no\":${ATTEMPT:-1},
|
||||||
\"scenario\":\"$SCENARIO\",
|
\"scenario\":\"$SCENARIO\",
|
||||||
|
\"call_type\":\"${TYPE:-dlv}\",
|
||||||
|
\"chosen_date\":\"${CHOSEN_DATE:-}\",
|
||||||
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||||
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||||
\"duration_sec\":0,
|
\"duration_sec\":0,
|
||||||
|
|
@ -104,6 +157,19 @@ save
|
||||||
say "ANSWER"
|
say "ANSWER"
|
||||||
say "EXEC Wait 1"
|
say "EXEC Wait 1"
|
||||||
|
|
||||||
|
# Сколько гудело до «ответа»: старт AGI минус время создания канала.
|
||||||
|
# Если «ответ» пришёл после долгого гудка, а цифр за весь диалог не было —
|
||||||
|
# это объявление оператора (сброс/недоступен), а не человек. Такие звонки
|
||||||
|
# пишем как no_answer, а не «не нажал». Порог 20 сек: живые обычно берут
|
||||||
|
# раньше; твой сброшенный звонок гудел ~25 сек до объявления.
|
||||||
|
if [ -n "$UNIQ" ]; then
|
||||||
|
RING_SEC=$(($(date +%s) - ${UNIQ%%.*}))
|
||||||
|
[ "$RING_SEC" -lt 0 ] && RING_SEC=0
|
||||||
|
else
|
||||||
|
RING_SEC=0
|
||||||
|
fi
|
||||||
|
ANY_DIGIT=""
|
||||||
|
|
||||||
# ─── C: подтверждение уже выбранного слота ───────────────────────────
|
# ─── C: подтверждение уже выбранного слота ───────────────────────────
|
||||||
if [ "$SCENARIO" = "C" ]; then
|
if [ "$SCENARIO" = "C" ]; then
|
||||||
play "ru/ss_c_intro_${TYPE}"
|
play "ru/ss_c_intro_${TYPE}"
|
||||||
|
|
@ -121,6 +187,183 @@ if [ "$SCENARIO" = "C" ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─── Самовывоз: даты приходят из call-файла (PICKUP_DAY1..3) ─────────
|
||||||
|
# Робот называет конкретные дни, окно считает планировщик:
|
||||||
|
# готов сегодня → сегодня/завтра/послезавтра, вчера → сегодня/завтра, позавчера → только сегодня.
|
||||||
|
# Отвечаем выбранной датой (chosen_date), а не абстрактным «день 1/2».
|
||||||
|
if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
|
||||||
|
play "ru/ss_pkp_intro"
|
||||||
|
DAYS=1
|
||||||
|
[ -n "$PICKUP_DAY2" ] && DAYS=2
|
||||||
|
[ -n "$PICKUP_DAY3" ] && DAYS=3
|
||||||
|
|
||||||
|
# Дни идут подряд от сегодня → играем готовые подсказки (живая фраза целиком).
|
||||||
|
# Если внутри окна выходной (например, пятница → понедельник), подсказка
|
||||||
|
# «послезавтра» соврала бы — собираем меню из фрагментов и называем реальные дни.
|
||||||
|
PICK_TODAY=$(date +%F)
|
||||||
|
PICK_TOMORROW=$(date -d "+1 day" +%F)
|
||||||
|
PICK_DAYAFTER=$(date -d "+2 days" +%F)
|
||||||
|
CONTIG=1
|
||||||
|
# Первый вариант — всегда «сегодня»; если окно начинается не с сегодня
|
||||||
|
# (звонок попал на выходной), подсказка «сегодня» соврала бы.
|
||||||
|
if [ "$PICKUP_DAY1" != "$PICK_TODAY" ]; then CONTIG=0; fi
|
||||||
|
if [ "$DAYS" -ge 2 ] && [ "$PICKUP_DAY2" != "$PICK_TOMORROW" ]; then CONTIG=0; fi
|
||||||
|
if [ "$DAYS" -ge 3 ] && [ "$PICKUP_DAY3" != "$PICK_DAYAFTER" ]; then CONTIG=0; fi
|
||||||
|
|
||||||
|
if [ "$CONTIG" != "1" ]; then
|
||||||
|
day_frag () {
|
||||||
|
case "$1" in
|
||||||
|
"$PICK_TODAY") echo "ru/ss_pkp_d_today" ;;
|
||||||
|
"$PICK_TOMORROW") echo "ru/ss_pkp_d_tomorrow" ;;
|
||||||
|
*) echo "ru/ss_pkp_d_$(date -d "$1" +%u)" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
if [ "$DAYS" = "1" ]; then
|
||||||
|
PICK_MENU="ru/ss_pkp_only&$(day_frag "$PICKUP_DAY1")"
|
||||||
|
else
|
||||||
|
PICK_MENU="ru/ss_pkp_menu1&$(day_frag "$PICKUP_DAY1")"
|
||||||
|
[ -n "$PICKUP_DAY2" ] && PICK_MENU="${PICK_MENU}&ru/ss_pkp_menu2&$(day_frag "$PICKUP_DAY2")"
|
||||||
|
[ -n "$PICKUP_DAY3" ] && PICK_MENU="${PICK_MENU}&ru/ss_pkp_menu3&$(day_frag "$PICKUP_DAY3")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [ -z "$OUTCOME" ]; do
|
||||||
|
if [ "$CONTIG" = "1" ]; then
|
||||||
|
digit "ru/ss_pkp_day${DAYS}" 9000
|
||||||
|
else
|
||||||
|
say "EXEC Playback \"$PICK_MENU\""
|
||||||
|
wait_digit 9000
|
||||||
|
fi
|
||||||
|
CHOSEN_DAY=$DIGIT
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
|
||||||
|
*) CHOSEN_DAY="" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$CHOSEN_DAY" ]; then
|
||||||
|
if [ "$CONTIG" = "1" ]; then
|
||||||
|
digit "ru/ss_pkp_day${DAYS}" 9000
|
||||||
|
else
|
||||||
|
say "EXEC Playback \"$PICK_MENU\""
|
||||||
|
wait_digit 9000
|
||||||
|
fi
|
||||||
|
CHOSEN_DAY=$DIGIT
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
|
||||||
|
*) CHOSEN_DAY="" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [ -z "$CHOSEN_DAY" ]; then
|
||||||
|
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
|
OUTCOME=no_answer; break
|
||||||
|
fi
|
||||||
|
play "ru/ss_no_input"; OUTCOME=no_input_day; break
|
||||||
|
fi
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1) CHOSEN_DATE="$PICKUP_DAY1" ;;
|
||||||
|
2) CHOSEN_DATE="$PICKUP_DAY2" ;;
|
||||||
|
3) CHOSEN_DATE="$PICKUP_DAY3" ;;
|
||||||
|
esac
|
||||||
|
DAY=$CHOSEN_DAY
|
||||||
|
save
|
||||||
|
# Половина дня: если осталось только «сегодня» и первая половина уже прошла — не спрашиваем.
|
||||||
|
if [ "$CHOSEN_DAY" = "1" ] && [ "$PICKUP_DAY1_FIRST" != "1" ]; then
|
||||||
|
HALF=2
|
||||||
|
play "ru/ss_ok_second"
|
||||||
|
else
|
||||||
|
digit "ru/ss_half_pkp" 9000
|
||||||
|
HALF=$DIGIT
|
||||||
|
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
||||||
|
digit "ru/ss_half_pkp" 9000
|
||||||
|
HALF=$DIGIT
|
||||||
|
fi
|
||||||
|
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
||||||
|
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
|
OUTCOME=no_answer; break
|
||||||
|
fi
|
||||||
|
play "ru/ss_no_input"; OUTCOME=no_input_half; break
|
||||||
|
fi
|
||||||
|
[ "$HALF" = "1" ] && play "ru/ss_ok_first" || play "ru/ss_ok_second"
|
||||||
|
fi
|
||||||
|
CONF_DAY=$CHOSEN_DAY; CONF_HALF=$HALF
|
||||||
|
OUTCOME=confirmed
|
||||||
|
play "ru/ss_tail_pkp"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Доставка: даты приходят из call-файла (DELIVERY_DAY1..2) ────────
|
||||||
|
# Доставляем только рабочими днями из «Графика работы» (админка) и не в день
|
||||||
|
# готовности: готов в пятницу → предлагаем понедельник и вторник, а не субботу
|
||||||
|
# с воскресеньем. Робот называет реальные дни, окно считает планировщик.
|
||||||
|
if [ "$TYPE" = "dlv" ] && [ -n "$DELIVERY_DAY1" ]; then
|
||||||
|
play "ru/ss_dlv_intro"
|
||||||
|
DLV_DAYS=1
|
||||||
|
[ -n "$DELIVERY_DAY2" ] && DLV_DAYS=2
|
||||||
|
|
||||||
|
DLV_TODAY=$(date +%F)
|
||||||
|
DLV_TOMORROW=$(date -d "+1 day" +%F)
|
||||||
|
dlv_day_frag () {
|
||||||
|
case "$1" in
|
||||||
|
"$DLV_TODAY") echo "ru/ss_pkp_d_today" ;;
|
||||||
|
"$DLV_TOMORROW") echo "ru/ss_pkp_d_tomorrow" ;;
|
||||||
|
*) echo "ru/ss_pkp_d_$(date -d "$1" +%u)" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
if [ "$DLV_DAYS" = "1" ]; then
|
||||||
|
DLV_MENU="ru/ss_dlv_only&$(dlv_day_frag "$DELIVERY_DAY1")"
|
||||||
|
else
|
||||||
|
DLV_MENU="ru/ss_dlv_menu1&$(dlv_day_frag "$DELIVERY_DAY1")&ru/ss_dlv_menu2&$(dlv_day_frag "$DELIVERY_DAY2")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [ -z "$OUTCOME" ]; do
|
||||||
|
say "EXEC Playback \"$DLV_MENU\""
|
||||||
|
wait_digit 9000
|
||||||
|
CHOSEN_DAY=$DIGIT
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1|2) [ "$CHOSEN_DAY" -le "$DLV_DAYS" ] || CHOSEN_DAY="" ;;
|
||||||
|
*) CHOSEN_DAY="" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$CHOSEN_DAY" ]; then
|
||||||
|
say "EXEC Playback \"$DLV_MENU\""
|
||||||
|
wait_digit 9000
|
||||||
|
CHOSEN_DAY=$DIGIT
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1|2) [ "$CHOSEN_DAY" -le "$DLV_DAYS" ] || CHOSEN_DAY="" ;;
|
||||||
|
*) CHOSEN_DAY="" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [ -z "$CHOSEN_DAY" ]; then
|
||||||
|
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
|
OUTCOME=no_answer; break
|
||||||
|
fi
|
||||||
|
play "ru/ss_no_input"; OUTCOME=no_input_day; break
|
||||||
|
fi
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1) CHOSEN_DATE="$DELIVERY_DAY1" ;;
|
||||||
|
2) CHOSEN_DATE="$DELIVERY_DAY2" ;;
|
||||||
|
esac
|
||||||
|
DAY=$CHOSEN_DAY
|
||||||
|
save
|
||||||
|
# Время доставки: до или после середины дня (границу задаёт админка,
|
||||||
|
# робот говорит просто «первая/вторая половина дня»).
|
||||||
|
digit "ru/ss_half_dlv" 9000
|
||||||
|
HALF=$DIGIT
|
||||||
|
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
||||||
|
digit "ru/ss_half_dlv" 9000
|
||||||
|
HALF=$DIGIT
|
||||||
|
fi
|
||||||
|
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
||||||
|
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
|
OUTCOME=no_answer; break
|
||||||
|
fi
|
||||||
|
play "ru/ss_no_input"; OUTCOME=no_input_half; break
|
||||||
|
fi
|
||||||
|
[ "$HALF" = "1" ] && play "ru/ss_ok_first" || play "ru/ss_ok_second"
|
||||||
|
CONF_DAY=$CHOSEN_DAY; CONF_HALF=$HALF
|
||||||
|
OUTCOME=confirmed
|
||||||
|
play "ru/ss_tail_dlv"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# ─── выбор дня и времени, с возможностью переделать ──────────────────
|
# ─── выбор дня и времени, с возможностью переделать ──────────────────
|
||||||
while [ -z "$OUTCOME" ]; do
|
while [ -z "$OUTCOME" ]; do
|
||||||
|
|
||||||
|
|
@ -128,7 +371,7 @@ while [ -z "$OUTCOME" ]; do
|
||||||
if [ "$SCENARIO" = "A" ]; then
|
if [ "$SCENARIO" = "A" ]; then
|
||||||
digit "ru/ss_a_day_${TYPE}"
|
digit "ru/ss_a_day_${TYPE}"
|
||||||
DAY=$DIGIT
|
DAY=$DIGIT
|
||||||
if [ "$DAY" != "1" ] && [ "$DAY" != "2" ] && [ "$DAY" != "0" ] && [ "$DAY" != "9" ]; then
|
if [ "$DAY" != "1" ] && [ "$DAY" != "2" ] && [ "$DAY" != "0" ]; then
|
||||||
digit "ru/ss_a_day_${TYPE}"
|
digit "ru/ss_a_day_${TYPE}"
|
||||||
DAY=$DIGIT
|
DAY=$DIGIT
|
||||||
fi
|
fi
|
||||||
|
|
@ -137,11 +380,10 @@ while [ -z "$OUTCOME" ]; do
|
||||||
fi
|
fi
|
||||||
save
|
save
|
||||||
|
|
||||||
if [ "$DAY" = "9" ]; then
|
if [ "$DAY" != "1" ] && [ "$DAY" != "2" ]; then
|
||||||
play "ru/ss_optout"; OUTCOME=opt_out; break
|
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
elif [ "$DAY" = "0" ]; then
|
OUTCOME=no_answer; break # объявление оператора, человека не было
|
||||||
play "ru/ss_operator"; OUTCOME=operator; break
|
fi
|
||||||
elif [ "$DAY" != "1" ] && [ "$DAY" != "2" ]; then
|
|
||||||
play "ru/ss_no_input"; OUTCOME=no_input_day; break
|
play "ru/ss_no_input"; OUTCOME=no_input_day; break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -162,6 +404,9 @@ while [ -z "$OUTCOME" ]; do
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
||||||
|
if [ -z "$ANY_DIGIT" ] && [ "$RING_SEC" -ge 20 ]; then
|
||||||
|
OUTCOME=no_answer; break # объявление оператора, человека не было
|
||||||
|
fi
|
||||||
play "ru/ss_no_input"; OUTCOME=no_input_half; break
|
play "ru/ss_no_input"; OUTCOME=no_input_half; break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -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,
|
"delay_after_sms_minutes": 90, "retry_interval_minutes": 120,
|
||||||
"attempts_per_day": 3, "max_attempts": 5,
|
"attempts_per_day": 3, "max_attempts": 5,
|
||||||
"no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00",
|
"no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00",
|
||||||
"call_timeout_seconds": 30,
|
"call_timeout_seconds": 30, "pickup_calls_enabled": False,
|
||||||
}
|
}
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
def get_groups_to_call(conn, settings):
|
def resolve_scenario(created_at, now=None):
|
||||||
"""Группы, которым пора звонить."""
|
"""Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра),
|
||||||
delay_min = settings.get("delay_after_sms_minutes", 90)
|
со следующего дня → B (день фиксирован = завтра, только половина дня)."""
|
||||||
retry_min = settings.get("retry_interval_minutes", 120)
|
if not created_at:
|
||||||
max_attempts = settings.get("max_attempts", 5)
|
return "A"
|
||||||
no_input_escalate = settings.get("no_input_escalate_after", 2)
|
now = now or datetime.now(timezone.utc)
|
||||||
|
msk_tz = timezone(timedelta(hours=3))
|
||||||
|
created = created_at.astimezone(msk_tz)
|
||||||
|
current = now.astimezone(msk_tz)
|
||||||
|
return "A" if created.date() == current.date() else "B"
|
||||||
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute("""
|
GROUPS_TO_CALL_SQL = """
|
||||||
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
||||||
og.notification_status, og.delivery_status, og.sms_sent_at,
|
og.notification_status, og.delivery_status, og.sms_sent_at,
|
||||||
og.autocall_opt_out,
|
og.autocall_opt_out, og.created_at, og.delivery_type, og.town,
|
||||||
(SELECT count(*) FROM autocall_log l
|
(SELECT count(*) FROM autocall_log l
|
||||||
WHERE l.order_group_id = og.id) as attempt_count,
|
WHERE l.order_group_id = og.id) as attempt_count,
|
||||||
(SELECT count(*) FROM autocall_log l
|
(SELECT count(*) FROM autocall_log l
|
||||||
WHERE l.order_group_id = og.id
|
WHERE l.order_group_id = og.id
|
||||||
AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count,
|
AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count,
|
||||||
(SELECT max(l.created_at) FROM autocall_log l
|
(SELECT max(l.created_at) FROM autocall_log l
|
||||||
WHERE l.order_group_id = og.id) as last_call_at
|
WHERE l.order_group_id = og.id) as last_call_at,
|
||||||
|
-- От чего считаем паузу перед звонком:
|
||||||
|
-- * SMS ушло — от времени отправки;
|
||||||
|
-- * SMS не ушло/не доставлено — от последней попытки отправки
|
||||||
|
-- (владелец: «если SMS не отправлено — звоним через 90 минут
|
||||||
|
-- после последней попытки»).
|
||||||
|
COALESCE(
|
||||||
|
CASE WHEN t.try_at IS NOT NULL
|
||||||
|
AND t.try_status NOT IN ('sent', 'delivered')
|
||||||
|
THEN t.try_at END,
|
||||||
|
og.sms_sent_at
|
||||||
|
) as call_after
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(l.updated_at, l.created_at) AS try_at, l.status AS try_status
|
||||||
|
FROM sms_campaign_log l
|
||||||
|
WHERE l.order_group_id = og.id
|
||||||
|
AND l.campaign_type IN ('first_sms', 'second_sms')
|
||||||
|
ORDER BY COALESCE(l.updated_at, l.created_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
) t ON true
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
AND og.autocall_opt_out = false
|
AND og.autocall_opt_out = false
|
||||||
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out')
|
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out')
|
||||||
AND og.sms_sent_at IS NOT NULL
|
-- Пауза после SMS (или после неудачной попытки) прошла
|
||||||
-- Пауза после SMS прошла
|
AND COALESCE(
|
||||||
AND og.sms_sent_at < NOW() - make_interval(mins => %s)
|
CASE WHEN t.try_at IS NOT NULL
|
||||||
|
AND t.try_status NOT IN ('sent', 'delivered')
|
||||||
|
THEN t.try_at END,
|
||||||
|
og.sms_sent_at
|
||||||
|
) IS NOT NULL
|
||||||
|
AND COALESCE(
|
||||||
|
CASE WHEN t.try_at IS NOT NULL
|
||||||
|
AND t.try_status NOT IN ('sent', 'delivered')
|
||||||
|
THEN t.try_at END,
|
||||||
|
og.sms_sent_at
|
||||||
|
) < NOW() - make_interval(mins => %s)
|
||||||
-- Не звонили или прошёл интервал повтора
|
-- Не звонили или прошёл интервал повтора
|
||||||
AND (
|
AND (
|
||||||
NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id)
|
NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id)
|
||||||
|
|
@ -108,8 +141,41 @@ def get_groups_to_call(conn, settings):
|
||||||
-- Не превышен лимит попыток
|
-- Не превышен лимит попыток
|
||||||
AND (SELECT count(*) FROM autocall_log l
|
AND (SELECT count(*) FROM autocall_log l
|
||||||
WHERE l.order_group_id = og.id) < %s
|
WHERE l.order_group_id = og.id) < %s
|
||||||
ORDER BY og.sms_sent_at ASC
|
-- Гео-исключения: те же, что у SMS (город исключён целиком
|
||||||
""", (delay_min, retry_min, max_attempts))
|
-- или канал для города выключен в профиле).
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_rules r
|
||||||
|
WHERE r.city = og.town
|
||||||
|
AND r.enabled = false
|
||||||
|
AND r.campaign_type IN ('*', 'autocall_slot')
|
||||||
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_profiles p
|
||||||
|
WHERE p.city = og.town AND p.sms_enabled = false
|
||||||
|
)
|
||||||
|
-- Локация выключена для звонков в админке автодозвона
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM autocall_city_rules c
|
||||||
|
WHERE c.city = og.town AND c.calls_enabled = false
|
||||||
|
)
|
||||||
|
-- Самовывоз звоним только если разрешено настройкой
|
||||||
|
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
|
||||||
|
ORDER BY call_after ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_groups_to_call(conn, settings):
|
||||||
|
"""Группы, которым пора звонить."""
|
||||||
|
delay_min = settings.get("delay_after_sms_minutes", 90)
|
||||||
|
retry_min = settings.get("retry_interval_minutes", 120)
|
||||||
|
max_attempts = settings.get("max_attempts", 5)
|
||||||
|
pickup_enabled = bool(settings.get("pickup_calls_enabled", False))
|
||||||
|
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(GROUPS_TO_CALL_SQL,
|
||||||
|
(delay_min, retry_min, max_attempts, pickup_enabled))
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def is_work_time(settings, now=None):
|
def is_work_time(settings, now=None):
|
||||||
|
|
@ -136,8 +202,226 @@ def normalize_phone(phone):
|
||||||
clean = "7" + clean
|
clean = "7" + clean
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
def build_call_file(group, settings, attempt_no, is_test_call=False):
|
def get_city_exclusion(conn, city):
|
||||||
"""Формирует содержимое call-файла."""
|
"""Гео-исключение — общее для SMS и звонков.
|
||||||
|
|
||||||
|
Источник тот же, что у SMS: правила города (sms_city_rules) и общий
|
||||||
|
выключатель канала в профиле города (sms_city_profiles.sms_enabled).
|
||||||
|
Правило с campaign_type='*' действует на все каналы, 'autocall_slot' —
|
||||||
|
только на звонки.
|
||||||
|
|
||||||
|
Исключения по номеру (например, «не звонить на МТС», autocall_exclusions)
|
||||||
|
живут отдельно и на SMS не влияют — они про телефонию, а не про город.
|
||||||
|
"""
|
||||||
|
if not city:
|
||||||
|
return None
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT city, campaign_type, note
|
||||||
|
FROM sms_city_rules
|
||||||
|
WHERE city = %s
|
||||||
|
AND enabled = false
|
||||||
|
AND campaign_type IN ('*', 'autocall_slot')
|
||||||
|
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||||
|
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||||
|
LIMIT 1
|
||||||
|
""", (city,))
|
||||||
|
rule = cur.fetchone()
|
||||||
|
if rule:
|
||||||
|
return {
|
||||||
|
"source": "city_rule",
|
||||||
|
"reason": (rule.get("note") or "").strip() or f"город {city} исключён из рассылок",
|
||||||
|
}
|
||||||
|
cur.execute("SELECT sms_enabled, note FROM sms_city_profiles WHERE city = %s LIMIT 1", (city,))
|
||||||
|
profile = cur.fetchone()
|
||||||
|
if profile is not None and profile.get("sms_enabled") is False:
|
||||||
|
return {
|
||||||
|
"source": "city_profile",
|
||||||
|
"reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен",
|
||||||
|
}
|
||||||
|
# Только звонки: переключатель локации в админке автодозвона
|
||||||
|
# (на SMS не влияет — уведомления по городу продолжают уходить).
|
||||||
|
cur.execute("SELECT calls_enabled, note FROM autocall_city_rules WHERE city = %s LIMIT 1", (city,))
|
||||||
|
calls_rule = cur.fetchone()
|
||||||
|
if calls_rule is not None and calls_rule.get("calls_enabled") is False:
|
||||||
|
return {
|
||||||
|
"source": "city_calls_off",
|
||||||
|
"reason": (calls_rule.get("note") or "").strip() or f"звонки по {city} выключены в админке",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_exclusion_match(conn, phone):
|
||||||
|
"""Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
|
||||||
|
phone = normalize_phone(phone)
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT rule_type, value, reason
|
||||||
|
FROM autocall_exclusions
|
||||||
|
WHERE active = true
|
||||||
|
AND (
|
||||||
|
(rule_type = 'phone' AND %s = value)
|
||||||
|
OR
|
||||||
|
(rule_type = 'prefix' AND %s LIKE value || '%%')
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""", (phone, phone))
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
def load_work_days(conn, field="pickup_days"):
|
||||||
|
"""Рабочие дни по графику компании (1=пн … 7=вс).
|
||||||
|
|
||||||
|
График правит админ в «График работы» (business_schedule_settings):
|
||||||
|
`pickup_days` — дни склада, `delivery_days` — дни доставки. Звонки обязаны
|
||||||
|
опираться именно на эти данные: в пятницу «послезавтра» — это воскресенье,
|
||||||
|
когда ни самовывоз, ни доставка не работают.
|
||||||
|
"""
|
||||||
|
columns = {"pickup_days", "delivery_days"} # белый список: имя колонки идёт в SQL
|
||||||
|
column = field if field in columns else "pickup_days"
|
||||||
|
default = [1, 2, 3, 4, 5]
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(f"SELECT {column} AS days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
raw = row.get("days") or default
|
||||||
|
days = sorted({int(str(d).strip()) for d in raw if str(d).strip().isdigit()})
|
||||||
|
return days or default
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Не удалось прочитать график работы ({column}), беру пн-пт: {e}")
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def load_work_hours(conn):
|
||||||
|
"""Рабочие часы склада и доставки из «Графика работы» (админка).
|
||||||
|
|
||||||
|
Возвращает минуты от полуночи — сравнивать часы строками нельзя.
|
||||||
|
Дефолты повторяют прежнее поведение кода: первая половина до 12:00,
|
||||||
|
«сегодня» предлагалось весь день.
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
"pickup_half_end": 12 * 60,
|
||||||
|
"pickup_same_day_cutoff": 24 * 60,
|
||||||
|
"pickup_close": 18 * 60,
|
||||||
|
"delivery_half_end": 15 * 60,
|
||||||
|
"delivery_close": 21 * 60,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT pickup_half_end, pickup_same_day_cutoff, pickup_close,
|
||||||
|
delivery_half_end, delivery_close
|
||||||
|
FROM business_schedule_settings WHERE id = 1
|
||||||
|
""")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
hours = {}
|
||||||
|
for key, fallback in defaults.items():
|
||||||
|
value = row.get(key)
|
||||||
|
if value is None:
|
||||||
|
hours[key] = fallback
|
||||||
|
continue
|
||||||
|
hours[key] = value.hour * 60 + value.minute
|
||||||
|
return hours
|
||||||
|
except Exception as exc: # noqa: BLE001 — часы не критичны, звоним по дефолту
|
||||||
|
log.warning("Не удалось прочитать рабочие часы, беру значения по умолчанию: %s", exc)
|
||||||
|
return dict(defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def work_day_window(start_date, work_days, count):
|
||||||
|
"""`count` ближайших рабочих дней начиная со `start_date` включительно.
|
||||||
|
|
||||||
|
Выходные и нерабочие дни не сокращают окно, а пропускаются.
|
||||||
|
"""
|
||||||
|
days = []
|
||||||
|
day = start_date
|
||||||
|
guard = 0
|
||||||
|
while len(days) < count and guard < 30:
|
||||||
|
if day.isoweekday() in work_days:
|
||||||
|
days.append(day)
|
||||||
|
day += timedelta(days=1)
|
||||||
|
guard += 1
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
|
||||||
|
"""Даты, на которые предлагаем самовывоз.
|
||||||
|
|
||||||
|
Правило владельца: окно считается **рабочими днями** — день готовности
|
||||||
|
плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают
|
||||||
|
окно, а пропускаются):
|
||||||
|
• готов в понедельник → пн, вт, ср
|
||||||
|
• готов в пятницу → пт, пн, вт (сб и вс склад закрыт)
|
||||||
|
• готов в субботу → пн, вт (сам выходной не предлагаем)
|
||||||
|
• готов вчера → остаток окна начиная с сегодня
|
||||||
|
• окно прошло → пусто, звонить не о чем
|
||||||
|
|
||||||
|
cutoff_minutes — время, после которого «сегодня» уже не предлагаем
|
||||||
|
(склад не успеет собрать заказ к выдаче, настройка админки).
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
work_days = work_days or [1, 2, 3, 4, 5]
|
||||||
|
tz = timezone(timedelta(hours=3))
|
||||||
|
local_now = now.astimezone(tz)
|
||||||
|
today = local_now.date()
|
||||||
|
ready = (sms_sent_at or now).astimezone(tz).date()
|
||||||
|
|
||||||
|
window = work_day_window(ready, work_days, 3)
|
||||||
|
|
||||||
|
first_day = today
|
||||||
|
# Поздно для сегодняшнего забора — окно начинается со следующего дня.
|
||||||
|
if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes:
|
||||||
|
first_day = today + timedelta(days=1)
|
||||||
|
return [d.isoformat() for d in window if d >= first_day]
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_window(sms_sent_at, work_days=None, now=None):
|
||||||
|
"""Даты, на которые предлагаем доставку.
|
||||||
|
|
||||||
|
Правило владельца: доставляем **только рабочими днями** и никогда в день
|
||||||
|
готовности — нужно время собрать маршрут. Значит, окно — два ближайших
|
||||||
|
рабочих дня после готовности:
|
||||||
|
• готов в понедельник → вт, ср
|
||||||
|
• готов в пятницу → пн, вт (в субботу и воскресенье не доставляем)
|
||||||
|
• звонок в среду по заказу, готовому в понедельник → ср, чт
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
work_days = work_days or [1, 2, 3, 4, 5]
|
||||||
|
tz = timezone(timedelta(hours=3))
|
||||||
|
today = now.astimezone(tz).date()
|
||||||
|
ready = (sms_sent_at or now).astimezone(tz).date()
|
||||||
|
|
||||||
|
start = max(today, ready + timedelta(days=1))
|
||||||
|
return [d.isoformat() for d in work_day_window(start, work_days, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_first_half_available(now=None, half_end_minutes=None):
|
||||||
|
"""До границы половины дня (настройка склада, по умолчанию 12:00)
|
||||||
|
первую половину дня ещё можно предлагать клиенту."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
local = now.astimezone(timezone(timedelta(hours=3)))
|
||||||
|
limit = 12 * 60 if half_end_minutes is None else half_end_minutes
|
||||||
|
return local.hour * 60 + local.minute < limit
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_call_type(group):
|
||||||
|
"""Тип звонка: pkp (самовывоз) или dlv (доставка).
|
||||||
|
|
||||||
|
Приоритет — явный call_type (ручной/тестовый запрос из очереди): его
|
||||||
|
выбирает логист. Если не задан — определяем по типу получения заказа.
|
||||||
|
От типа зависит озвучка (ru/ss_*_pkp vs ru/ss_*_dlv).
|
||||||
|
"""
|
||||||
|
ct = (group.get("call_type") or "").strip().lower()
|
||||||
|
if ct in ("dlv", "pkp"):
|
||||||
|
return ct
|
||||||
|
return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv"
|
||||||
|
|
||||||
|
|
||||||
|
def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
|
||||||
|
"""Формирует содержимое call-файла.
|
||||||
|
|
||||||
|
manual=True — звонок запущен руками из карточки заказа. Логист выбрал
|
||||||
|
конкретного клиента, поэтому звоним ему, даже если кампания в тестовом
|
||||||
|
режиме: тестовый режим страхует массовый обзвон, а не ручной запрос.
|
||||||
|
"""
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
phone = normalize_phone(phone)
|
phone = normalize_phone(phone)
|
||||||
# Формат набора: 0 + номер без ведущей 7
|
# Формат набора: 0 + номер без ведущей 7
|
||||||
|
|
@ -148,12 +432,38 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
test_mode = bool(settings.get("test_mode", True))
|
test_mode = bool(settings.get("test_mode", True))
|
||||||
if is_test_call:
|
if is_test_call:
|
||||||
test_mode = True
|
test_mode = True
|
||||||
|
elif manual and group.get("id"):
|
||||||
|
test_mode = False
|
||||||
elif test_mode:
|
elif test_mode:
|
||||||
test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
|
test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
|
||||||
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
|
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
|
||||||
|
|
||||||
scenario = group.get("scenario", "A") # выбор дня + времени
|
# Сценарий: в день добавления группы → A (завтра/послезавтра),
|
||||||
call_type = "dlv"
|
# со следующего дня → B (только завтра + половина дня).
|
||||||
|
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
|
||||||
|
call_type = resolve_call_type(group)
|
||||||
|
|
||||||
|
# Самовывоз: конкретные даты передаём в диалог явно — робот называет их
|
||||||
|
# голосом и возвращает выбранную дату, а не абстрактный «день 1/2».
|
||||||
|
pickup_days = []
|
||||||
|
if call_type == "pkp":
|
||||||
|
pickup_days = pickup_window(
|
||||||
|
group.get("sms_sent_at") or group.get("created_at"),
|
||||||
|
settings.get("pickup_work_days"),
|
||||||
|
cutoff_minutes=(settings.get("work_hours") or {}).get("pickup_same_day_cutoff"),
|
||||||
|
)
|
||||||
|
if not pickup_days:
|
||||||
|
# Окно самовывоза закрыто (заказ готов более двух дней назад).
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Доставка: только рабочие дни графика и не в день готовности. Суббота —
|
||||||
|
# выходной, поэтому «готов в пятницу» даёт понедельник и вторник.
|
||||||
|
delivery_days = []
|
||||||
|
if call_type == "dlv":
|
||||||
|
delivery_days = delivery_window(
|
||||||
|
group.get("sms_sent_at") or group.get("created_at"),
|
||||||
|
settings.get("delivery_work_days"),
|
||||||
|
)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"Channel: PJSIP/{dial}@mmedia",
|
f"Channel: PJSIP/{dial}@mmedia",
|
||||||
|
|
@ -169,6 +479,12 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
f"Setvar: GROUP_ID={group.get('id') or ''}",
|
f"Setvar: GROUP_ID={group.get('id') or ''}",
|
||||||
f"Setvar: ATTEMPT={attempt_no}",
|
f"Setvar: ATTEMPT={attempt_no}",
|
||||||
f"Setvar: CUSTOMER_PHONE={phone}",
|
f"Setvar: CUSTOMER_PHONE={phone}",
|
||||||
|
f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 else ''}",
|
||||||
|
f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 else ''}",
|
||||||
|
f"Setvar: PICKUP_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}",
|
||||||
|
f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}",
|
||||||
|
f"Setvar: DELIVERY_DAY1={delivery_days[0] if len(delivery_days) > 0 else ''}",
|
||||||
|
f"Setvar: DELIVERY_DAY2={delivery_days[1] if len(delivery_days) > 1 else ''}",
|
||||||
f"Setvar: API_URL={API_URL}",
|
f"Setvar: API_URL={API_URL}",
|
||||||
f"Setvar: API_KEY={API_KEY}",
|
f"Setvar: API_KEY={API_KEY}",
|
||||||
f"Setvar: TEST_MODE={1 if test_mode else 0}",
|
f"Setvar: TEST_MODE={1 if test_mode else 0}",
|
||||||
|
|
@ -176,9 +492,13 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def push_call_file(group, settings, attempt_no, is_test_call=False):
|
def push_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
|
||||||
"""Кладёт call-файл на АТС через SSH."""
|
"""Кладёт call-файл на АТС через SSH."""
|
||||||
content = build_call_file(group, settings, attempt_no, is_test_call)
|
content = build_call_file(group, settings, attempt_no, is_test_call, manual)
|
||||||
|
if content is None:
|
||||||
|
# Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем.
|
||||||
|
log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка")
|
||||||
|
return False
|
||||||
gid = str(group.get("id") or "test")[:8]
|
gid = str(group.get("id") or "test")[:8]
|
||||||
filename = f"ss-{gid}-{int(time.time())}.call"
|
filename = f"ss-{gid}-{int(time.time())}.call"
|
||||||
# Пишем файл на АТС через sshpass + cat
|
# Пишем файл на АТС через sshpass + cat
|
||||||
|
|
@ -204,7 +524,8 @@ def get_queued_calls(conn):
|
||||||
"""Ручные запросы «Позвонить сейчас» из autocall_queue."""
|
"""Ручные запросы «Позвонить сейчас» из autocall_queue."""
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call
|
SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call,
|
||||||
|
q.call_type
|
||||||
FROM autocall_queue q
|
FROM autocall_queue q
|
||||||
WHERE q.processed_at IS NULL
|
WHERE q.processed_at IS NULL
|
||||||
ORDER BY q.requested_at ASC
|
ORDER BY q.requested_at ASC
|
||||||
|
|
@ -215,7 +536,9 @@ def get_queued_calls(conn):
|
||||||
ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"])
|
ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"])
|
||||||
if ids:
|
if ids:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.customer_phone, og.customer_phone_normalized
|
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
||||||
|
og.created_at, og.delivery_type, og.town,
|
||||||
|
og.notification_status, og.delivery_date, og.autocall_opt_out
|
||||||
FROM order_groups og WHERE og.id IN %s
|
FROM order_groups og WHERE og.id IN %s
|
||||||
""", (ids,))
|
""", (ids,))
|
||||||
og = {r["id"]: r for r in cur.fetchall()}
|
og = {r["id"]: r for r in cur.fetchall()}
|
||||||
|
|
@ -224,6 +547,12 @@ def get_queued_calls(conn):
|
||||||
if g:
|
if g:
|
||||||
r["customer_phone_normalized"] = g.get("customer_phone_normalized")
|
r["customer_phone_normalized"] = g.get("customer_phone_normalized")
|
||||||
r.setdefault("customer_phone", g.get("customer_phone"))
|
r.setdefault("customer_phone", g.get("customer_phone"))
|
||||||
|
r.setdefault("created_at", g.get("created_at"))
|
||||||
|
r.setdefault("delivery_type", g.get("delivery_type"))
|
||||||
|
r.setdefault("town", g.get("town"))
|
||||||
|
r["notification_status"] = g.get("notification_status")
|
||||||
|
r["delivery_date"] = g.get("delivery_date")
|
||||||
|
r["autocall_opt_out"] = g.get("autocall_opt_out")
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -249,6 +578,33 @@ def process_queue(conn, settings):
|
||||||
if not phone:
|
if not phone:
|
||||||
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
||||||
continue
|
continue
|
||||||
|
# Гео-исключение (как для SMS): город исключён целиком
|
||||||
|
city_excl = get_city_exclusion(conn, q.get("town"))
|
||||||
|
if city_excl:
|
||||||
|
log.info(f"Queue item {q['queue_id']} skipped by city rule ({q.get('town')}): {city_excl['reason']}")
|
||||||
|
mark_queue_processed(conn, q["queue_id"])
|
||||||
|
continue
|
||||||
|
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
||||||
|
excl = get_exclusion_match(conn, phone)
|
||||||
|
if excl:
|
||||||
|
log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
|
||||||
|
mark_queue_processed(conn, q["queue_id"])
|
||||||
|
continue
|
||||||
|
# Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) —
|
||||||
|
# роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено.
|
||||||
|
# Заявку НЕ закрываем (правило владельца) — просто не обрабатываем.
|
||||||
|
# ВАЖНО: 'manual_required' («нужен менеджер») сюда не относится — это как раз
|
||||||
|
# заказ, где клиент дату НЕ выбрал, звонок робота по нему допустим.
|
||||||
|
agreed = (str(q.get("notification_status") or "") == "confirmed"
|
||||||
|
or q.get("delivery_date") is not None
|
||||||
|
or q.get("autocall_opt_out") is True)
|
||||||
|
if agreed and not is_test:
|
||||||
|
log.info(
|
||||||
|
f"Queue item {q['queue_id']} (group {group_id}): заказ уже согласован "
|
||||||
|
f"(статус {q.get('notification_status')}, дата {q.get('delivery_date')}) — "
|
||||||
|
"звонок не нужен, заявка остаётся в очереди"
|
||||||
|
)
|
||||||
|
continue
|
||||||
if group_id not in attempt_map:
|
if group_id not in attempt_map:
|
||||||
if group_id:
|
if group_id:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
@ -263,9 +619,18 @@ def process_queue(conn, settings):
|
||||||
# Для теста не подставляем customer_phone_normalized (нет заказа)
|
# Для теста не подставляем customer_phone_normalized (нет заказа)
|
||||||
if is_test:
|
if is_test:
|
||||||
g["customer_phone_normalized"] = None
|
g["customer_phone_normalized"] = None
|
||||||
if push_call_file(g, settings, attempt_no, is_test_call=is_test):
|
if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
|
||||||
mark_queue_processed(conn, q["queue_id"])
|
mark_queue_processed(conn, q["queue_id"])
|
||||||
pushed += 1
|
pushed += 1
|
||||||
|
else:
|
||||||
|
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
|
||||||
|
# по правилу владельца заявка живёт, пока окно не откроется или
|
||||||
|
# пока её не обработают вручную. Интерфейс в этот момент блокирует
|
||||||
|
# кнопку автодозвона и объясняет причину (autocallAvailability.js).
|
||||||
|
log.info(
|
||||||
|
f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — "
|
||||||
|
"заявка остаётся в очереди до открытия окна"
|
||||||
|
)
|
||||||
attempt_map[group_id] = attempt_no + 1
|
attempt_map[group_id] = attempt_no + 1
|
||||||
return pushed
|
return pushed
|
||||||
|
|
||||||
|
|
@ -277,10 +642,109 @@ def has_pending_items(conn):
|
||||||
return cur.fetchone() is not None
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_no_answer(conn, settings):
|
||||||
|
"""Дописывает no_answer звонкам, которые не получили ответа сети.
|
||||||
|
|
||||||
|
Call-файл с истёкшим WaitTime не запускает AGI — в autocall_log ничего
|
||||||
|
не пишется, и «не дозвонились» не появляется никогда. Ищем обработанные
|
||||||
|
заявки старше 10 минут без строки в логе и дописываем no_answer.
|
||||||
|
Самозакрывающаяся: вставленная строка сама удовлетворяет проверке
|
||||||
|
существования, повторов не будет.
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT q.id, q.order_group_id, q.phone, q.is_test_call,
|
||||||
|
q.requested_at, q.processed_at, q.call_type
|
||||||
|
FROM autocall_queue q
|
||||||
|
WHERE q.processed_at IS NOT NULL
|
||||||
|
AND q.processed_at < NOW() - make_interval(mins => 10)
|
||||||
|
ORDER BY q.processed_at ASC
|
||||||
|
LIMIT 20
|
||||||
|
""")
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
backfilled = 0
|
||||||
|
for q in rows:
|
||||||
|
gid = q["order_group_id"]
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
if gid:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT 1 FROM autocall_log l
|
||||||
|
WHERE l.order_group_id = %s AND l.created_at >= %s LIMIT 1
|
||||||
|
""", (gid, q["requested_at"]))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT 1 FROM autocall_log l
|
||||||
|
WHERE l.order_group_id IS NULL
|
||||||
|
AND l.customer_phone = %s
|
||||||
|
AND l.created_at >= %s LIMIT 1
|
||||||
|
""", (normalize_phone(q["phone"] or ""), q["requested_at"]))
|
||||||
|
if cur.fetchone():
|
||||||
|
continue # строка есть (AGI отработал) — дописывать нечего
|
||||||
|
# Телефон из заказа (или нормализованный из заявки для pure-теста)
|
||||||
|
phone = normalize_phone(q["phone"] or "")
|
||||||
|
group_created = None
|
||||||
|
group_dtype = None
|
||||||
|
if gid:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT customer_phone_normalized, customer_phone, created_at,
|
||||||
|
delivery_type, town
|
||||||
|
FROM order_groups WHERE id = %s
|
||||||
|
""", (gid,))
|
||||||
|
grow = cur.fetchone() or {}
|
||||||
|
phone = (grow.get("customer_phone_normalized")
|
||||||
|
or normalize_phone(grow.get("customer_phone") or "")
|
||||||
|
or phone)
|
||||||
|
group_created = grow.get("created_at")
|
||||||
|
group_dtype = grow.get("delivery_type")
|
||||||
|
# По исключённым номерам звонка не было — строку не пишем
|
||||||
|
# (статус «исключение» и так показывается из настроек).
|
||||||
|
if get_exclusion_match(conn, phone):
|
||||||
|
continue
|
||||||
|
# Гео-исключение: город, по которому звонки не делаем вовсе
|
||||||
|
if get_city_exclusion(conn, q.get("town")):
|
||||||
|
continue
|
||||||
|
scenario = resolve_scenario(group_created) if gid else "A"
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if gid:
|
||||||
|
cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (gid,))
|
||||||
|
attempt_no = (cur.fetchone()[0] or 0) + 1
|
||||||
|
else:
|
||||||
|
attempt_no = 1
|
||||||
|
test_mode = bool(q["is_test_call"] or settings.get("test_mode", True))
|
||||||
|
call_type = resolve_call_type({"call_type": q.get("call_type"), "delivery_type": group_dtype})
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO autocall_log
|
||||||
|
(order_group_id, customer_phone, attempt_no, scenario,
|
||||||
|
call_started_at, outcome, was_test_mode, channel_state, call_type)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer', %s)
|
||||||
|
""", (gid, phone, attempt_no, scenario,
|
||||||
|
q["processed_at"] or q["requested_at"], test_mode, call_type))
|
||||||
|
conn.commit()
|
||||||
|
backfilled += 1
|
||||||
|
log.info(f"Backfilled no_answer for queue item {q['id']} (phone={phone})")
|
||||||
|
return backfilled
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
conn = get_db_conn()
|
conn = get_db_conn()
|
||||||
try:
|
try:
|
||||||
settings = load_settings(conn)
|
settings = load_settings(conn)
|
||||||
|
# Рабочие дни и часы из «Графика работы» (админка): выходные и нерабочие
|
||||||
|
# дни робот не предлагает ни по самовывозу, ни по доставке.
|
||||||
|
settings["pickup_work_days"] = load_work_days(conn, "pickup_days")
|
||||||
|
settings["delivery_work_days"] = load_work_days(conn, "delivery_days")
|
||||||
|
settings["work_hours"] = load_work_hours(conn)
|
||||||
|
|
||||||
|
# Добор «не взял трубку»: заявки, по которым сеть так и не ответила
|
||||||
|
# (истёкший call-файл, AGI не запускался). Только работа с БД.
|
||||||
|
try:
|
||||||
|
nb = backfill_no_answer(conn, settings)
|
||||||
|
if nb:
|
||||||
|
log.info(f"Backfilled {nb} no_answer rows")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"backfill_no_answer failed: {e}")
|
||||||
|
|
||||||
# Fast-path: если кампания выключена и очередь пуста — выходим молча.
|
# Fast-path: если кампания выключена и очередь пуста — выходим молча.
|
||||||
# Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе.
|
# Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе.
|
||||||
|
|
@ -305,6 +769,17 @@ def main():
|
||||||
|
|
||||||
pushed = 0
|
pushed = 0
|
||||||
for group in groups:
|
for group in groups:
|
||||||
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
|
||||||
|
# Гео-исключение (то же, что для SMS): город исключён целиком
|
||||||
|
city_excl = get_city_exclusion(conn, group.get("town"))
|
||||||
|
if city_excl:
|
||||||
|
log.info(f"Group {group['id']} skipped by city rule ({group.get('town')}): {city_excl['reason']}")
|
||||||
|
continue
|
||||||
|
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
||||||
|
excl = get_exclusion_match(conn, phone)
|
||||||
|
if excl:
|
||||||
|
log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
|
||||||
|
continue
|
||||||
attempt_no = (group.get("attempt_count") or 0) + 1
|
attempt_no = (group.get("attempt_count") or 0) + 1
|
||||||
if push_call_file(group, settings, attempt_no):
|
if push_call_file(group, settings, attempt_no):
|
||||||
pushed += 1
|
pushed += 1
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ def send_telegram(message, chat_id):
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
def get_groups_to_send(conn, max_attempts):
|
||||||
"""Группы, которым ещё не отправили SMS.
|
"""Группы, которым ещё не отправили SMS.
|
||||||
|
|
||||||
Режимы (из sms_city_profiles):
|
Режимы (из sms_city_profiles):
|
||||||
|
|
@ -183,7 +183,7 @@ def get_groups_to_send(conn):
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||||
og.delivery_type, og.town,
|
og.delivery_type, og.town, og.sms_attempts,
|
||||||
-- Профиль города (fallback на дефолтные значения если нет записи)
|
-- Профиль города (fallback на дефолтные значения если нет записи)
|
||||||
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
|
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
|
||||||
COALESCE(scp.delivery_text, '') as delivery_text,
|
COALESCE(scp.delivery_text, '') as delivery_text,
|
||||||
|
|
@ -210,6 +210,16 @@ def get_groups_to_send(conn):
|
||||||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
||||||
AND COALESCE(scp.sms_enabled, true) = true
|
AND COALESCE(scp.sms_enabled, true) = true
|
||||||
|
-- Гео-исключения — общие для SMS и звонков: правило города
|
||||||
|
-- (campaign_type '*' — все каналы) блокирует и отправку SMS.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_rules r
|
||||||
|
WHERE r.city = og.town
|
||||||
|
AND r.enabled = false
|
||||||
|
AND r.campaign_type IN ('*', 'first_sms')
|
||||||
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
@ -218,8 +228,15 @@ def get_groups_to_send(conn):
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND scl.status IN ('sent', 'checking')
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
)
|
)
|
||||||
|
-- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку,
|
||||||
|
-- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны)
|
||||||
|
AND (
|
||||||
|
SELECT count(*) FROM sms_campaign_log scl2
|
||||||
|
WHERE scl2.order_group_id = og.id
|
||||||
|
AND scl2.campaign_type = 'first_sms'
|
||||||
|
) < %s
|
||||||
ORDER BY og.created_at ASC
|
ORDER BY og.created_at ASC
|
||||||
""")
|
""", (max_attempts,))
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
|
@ -300,6 +317,22 @@ def update_order_group(conn, group_id, fields):
|
||||||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def count_sms_attempts(conn, group_id, campaign_type="first_sms"):
|
||||||
|
"""Сколько раз эту SMS уже пытались отправить (строк в логе этой кампании).
|
||||||
|
|
||||||
|
Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
|
||||||
|
новую строку, поэтому в самой строке всегда было бы attempts=1, условие
|
||||||
|
«attempts < max_attempts» выполнялось бы вечно и кампания слала бы SMS
|
||||||
|
каждые 5 минут бесконечно (проверено на заказе ТП-010543: 457 отправок за
|
||||||
|
3,5 суток, ни одна не доставлена, в ручную работу заказ не ушёл).
|
||||||
|
"""
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = %s
|
||||||
|
""", (group_id, campaign_type))
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def step_send_new(conn, settings):
|
def step_send_new(conn, settings):
|
||||||
|
|
@ -309,7 +342,7 @@ def step_send_new(conn, settings):
|
||||||
max_attempts = settings.get("max_attempts", 2)
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
|
|
||||||
groups = get_groups_to_send(conn)
|
groups = get_groups_to_send(conn, max_attempts)
|
||||||
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
|
@ -353,6 +386,8 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
||||||
|
|
||||||
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
|
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
attempt_no = count_sms_attempts(conn, group_id) + 1
|
||||||
|
log.info(f"Group {group_id}: attempt {attempt_no}/{max_attempts}")
|
||||||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
if sms_id:
|
if sms_id:
|
||||||
|
|
@ -364,7 +399,7 @@ def step_send_new(conn, settings):
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="sent",
|
status="sent",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
||||||
if sms_mode == "notification":
|
if sms_mode == "notification":
|
||||||
|
|
@ -374,6 +409,7 @@ def step_send_new(conn, settings):
|
||||||
"sms_sent_at": "NOW()",
|
"sms_sent_at": "NOW()",
|
||||||
"first_sms_sent_at": "NOW()",
|
"first_sms_sent_at": "NOW()",
|
||||||
"status": "first_sms_sent",
|
"status": "first_sms_sent",
|
||||||
|
"sms_attempts": attempt_no,
|
||||||
})
|
})
|
||||||
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
|
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
|
||||||
else:
|
else:
|
||||||
|
|
@ -381,6 +417,7 @@ def step_send_new(conn, settings):
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "sms_sending",
|
"notification_status": "sms_sending",
|
||||||
"sms_sent_at": "NOW()",
|
"sms_sent_at": "NOW()",
|
||||||
|
"sms_attempts": attempt_no,
|
||||||
})
|
})
|
||||||
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
|
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
@ -394,7 +431,7 @@ def step_send_new(conn, settings):
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="send_failed",
|
status="send_failed",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
error_message=error,
|
error_message=error,
|
||||||
)
|
)
|
||||||
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
||||||
|
|
@ -402,6 +439,7 @@ def step_send_new(conn, settings):
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "send_failed",
|
||||||
"last_sms_error": error[:200],
|
"last_sms_error": error[:200],
|
||||||
|
"sms_attempts": attempt_no,
|
||||||
})
|
})
|
||||||
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||||||
|
|
||||||
|
|
@ -425,7 +463,7 @@ def step_check_status(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts") or 1
|
||||||
was_needs_check = item.get("needs_check", False)
|
was_needs_check = item.get("needs_check", False)
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
@ -471,9 +509,11 @@ def step_check_status(conn, settings):
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "send_failed",
|
"notification_status": "manual_required",
|
||||||
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
"last_sms_error": f"SMS не доставлена после {max_attempts} попыток (код {code})",
|
||||||
})
|
})
|
||||||
|
log.error(f"Group {group_id}: attempts exhausted ({attempts}/{max_attempts}), "
|
||||||
|
f"notification_status→manual_required")
|
||||||
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
elif code in LIMIT_ERROR_CODES:
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
|
@ -509,7 +549,7 @@ def step_handle_expired(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts") or 1
|
||||||
delivery_link = item.get("delivery_link", "")
|
delivery_link = item.get("delivery_link", "")
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
|
|
@ -534,6 +574,63 @@ def step_handle_expired(conn, settings):
|
||||||
tg_chat,
|
tg_chat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def step_release_exhausted(conn, settings):
|
||||||
|
"""Шаг 4: группы, у которых попытки исчерпаны, а статус остался 'link_ready'.
|
||||||
|
|
||||||
|
Такое состояние возникает как следствие сбоя счётчика: группа выпадает из
|
||||||
|
выборки по лимиту попыток, но из очереди не уходит — висит в 'link_ready'
|
||||||
|
вечно, клиент не получает ссылку, в ручную работу заказ не переводится.
|
||||||
|
Шаг закрывает эту дыру: попытки кончились → «нужен менеджер».
|
||||||
|
"""
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
(SELECT count(*) FROM sms_campaign_log l
|
||||||
|
WHERE l.order_group_id = og.id
|
||||||
|
AND l.campaign_type = 'first_sms') AS attempts
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
|
AND (
|
||||||
|
SELECT count(*) FROM sms_campaign_log l
|
||||||
|
WHERE l.order_group_id = og.id
|
||||||
|
AND l.campaign_type = 'first_sms'
|
||||||
|
) >= %s
|
||||||
|
-- Активная отправка в работе — не мешаем ей
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log l2
|
||||||
|
WHERE l2.order_group_id = og.id
|
||||||
|
AND l2.campaign_type = 'first_sms'
|
||||||
|
AND l2.status IN ('sent', 'checking')
|
||||||
|
)
|
||||||
|
""", (max_attempts,))
|
||||||
|
groups = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
group_id = str(group["id"])
|
||||||
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
phone = group.get("customer_phone", "")
|
||||||
|
attempts = group.get("attempts") or max_attempts
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: попыток {attempts}/{max_attempts}, статус остался link_ready "
|
||||||
|
f"→ manual_required (клиент SMS не получил)")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"SMS не доставлена после {attempts} попыток — нужен менеджер",
|
||||||
|
})
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 <b>Нужен менеджер</b>\n{name} ({phone})\n"
|
||||||
|
f"SMS не доставлена после {attempts} попыток — клиенту не ушла ссылка",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
|
||||||
|
if groups:
|
||||||
|
log.info(f"Step 4: {len(groups)} groups released from link_ready → manual_required")
|
||||||
|
return len(groups)
|
||||||
|
|
||||||
# ─── Main ────────────────────────────────────────────────────────────────────
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -555,8 +652,9 @@ def main():
|
||||||
sent = step_send_new(conn, settings)
|
sent = step_send_new(conn, settings)
|
||||||
delivered = step_check_status(conn, settings)
|
delivered = step_check_status(conn, settings)
|
||||||
step_handle_expired(conn, settings)
|
step_handle_expired(conn, settings)
|
||||||
|
released = step_release_exhausted(conn, settings)
|
||||||
|
|
||||||
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
log.info(f"Run summary: sent={sent}, delivered={delivered}, released_to_manual={released}")
|
||||||
|
|
||||||
if sent > 0 or delivered > 0:
|
if sent > 0 or delivered > 0:
|
||||||
send_telegram(
|
send_telegram(
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,22 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
user=DB_USER, password=DB_PASS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_schedule_work_days(conn, settings):
|
||||||
|
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
|
||||||
|
|
||||||
|
Если график прочитать не удалось, остаются прежние дни кампании.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
days = row.get("sms_days") or []
|
||||||
|
if days:
|
||||||
|
settings["work_days"] = ",".join(str(int(d)) for d in days)
|
||||||
|
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
|
||||||
|
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
|
||||||
|
return settings
|
||||||
|
|
||||||
def load_settings(conn):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||||
|
|
@ -99,7 +115,8 @@ def load_settings(conn):
|
||||||
"test_mode": True,
|
"test_mode": True,
|
||||||
"test_phone": "79788382260",
|
"test_phone": "79788382260",
|
||||||
}
|
}
|
||||||
return dict(row)
|
settings = dict(row)
|
||||||
|
return apply_schedule_work_days(conn, settings)
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine)
|
||||||
Заменяет n8n workflow "Ручное управление"
|
Заменяет n8n workflow "Ручное управление"
|
||||||
|
|
||||||
Логика:
|
Логика:
|
||||||
Если после второй SMS (notification_status = 'second_sms_sent')
|
Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч)
|
||||||
прошло auto_manual_after_hours и клиент не согласовал
|
после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и
|
||||||
(delivery_status = 'pending_confirmation'):
|
(delivery_status = 'pending_confirmation'):
|
||||||
→ notification_status = 'manual_required'
|
→ notification_status = 'manual_required'
|
||||||
→ delivery_status = 'manual_confirmation_required'
|
→ delivery_status = 'manual_confirmation_required'
|
||||||
→ next_notification_check_at = +3 мин
|
|
||||||
→ Telegram-уведомление
|
→ Telegram-уведомление
|
||||||
|
|
||||||
|
Ожидание считаем только по тем каналам, что реально включены в системе
|
||||||
|
(load_channels):
|
||||||
|
• второе приглашение включено → ждём, пока оно уйдёт;
|
||||||
|
• автодозвон включён и номеру звонить можно → ждём звонок, отсчёт
|
||||||
|
начнётся от него (позвонили позже — ожидание начинается заново);
|
||||||
|
• канал выключен / звонить нельзя (не звонить, гео, исключение) —
|
||||||
|
не ждём его и переводим по сроку ожидания.
|
||||||
|
|
||||||
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
||||||
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
||||||
"""
|
"""
|
||||||
|
|
@ -61,13 +68,30 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
user=DB_USER, password=DB_PASS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_schedule_work_days(conn, settings):
|
||||||
|
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
|
||||||
|
|
||||||
|
Если график прочитать не удалось, остаются прежние дни кампании.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
days = row.get("sms_days") or []
|
||||||
|
if days:
|
||||||
|
settings["work_days"] = ",".join(str(int(d)) for d in days)
|
||||||
|
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
|
||||||
|
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
|
||||||
|
return settings
|
||||||
|
|
||||||
def load_settings(conn):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
|
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
|
||||||
return dict(row)
|
settings = dict(row)
|
||||||
|
return apply_schedule_work_days(conn, settings)
|
||||||
|
|
||||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -94,52 +118,129 @@ def is_within_work_hours(settings):
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_manual(conn):
|
def get_groups_to_manual(conn, channels=None, manual_after_hours=3):
|
||||||
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
"""Группы, где клиент не выбрал дату/время и пора в ручную работу.
|
||||||
|
|
||||||
Сценарии:
|
Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого
|
||||||
1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS
|
приглашения: если клиенту позвонили позже, ожидание начинается заново.
|
||||||
2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS
|
|
||||||
3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа
|
Каналы берём из настроек: ждём только те шаги, которые реально включены.
|
||||||
4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа
|
• второе приглашение включено → ждём, пока оно уйдёт;
|
||||||
|
• автодозвон включён и этому номеру звонить можно → ждём звонка;
|
||||||
|
• канал выключен (или звонить нельзя: не звонить/гео/исключение) —
|
||||||
|
не ждём его и переводим в ручную работу по сроку ожидания.
|
||||||
|
|
||||||
|
Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие
|
||||||
|
заказы в системе ведёт только менеджер, автоматика их не трогает.
|
||||||
"""
|
"""
|
||||||
|
channels = channels or {}
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute(r"""
|
||||||
|
WITH contact AS (
|
||||||
|
SELECT og.id,
|
||||||
|
GREATEST(
|
||||||
|
COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'),
|
||||||
|
COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'),
|
||||||
|
COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'),
|
||||||
|
COALESCE(og.called_at, TIMESTAMP 'epoch'),
|
||||||
|
COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l
|
||||||
|
WHERE l.order_group_id = og.id
|
||||||
|
AND COALESCE(l.status, '') IN
|
||||||
|
('sent','checking','delivered','send_failed','error','limit_exceeded')),
|
||||||
|
TIMESTAMP 'epoch'),
|
||||||
|
COALESCE((SELECT max(a.created_at) FROM autocall_log a
|
||||||
|
WHERE a.order_group_id = og.id), TIMESTAMP 'epoch')
|
||||||
|
) AS last_contact_at,
|
||||||
|
(SELECT count(*) FROM autocall_log a
|
||||||
|
WHERE a.order_group_id = og.id) AS call_attempts
|
||||||
|
FROM order_groups og
|
||||||
|
),
|
||||||
|
callable AS (
|
||||||
|
SELECT og.id,
|
||||||
|
(og.autocall_opt_out = false
|
||||||
|
AND COALESCE(og.customer_phone, '') <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM autocall_city_rules cr
|
||||||
|
WHERE cr.city = og.town AND cr.calls_enabled = false)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_rules cr2
|
||||||
|
WHERE cr2.city = og.town AND cr2.enabled = false
|
||||||
|
AND cr2.campaign_type IN ('*', 'autocall_slot')
|
||||||
|
AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE)
|
||||||
|
AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM autocall_exclusions e
|
||||||
|
WHERE e.active = true
|
||||||
|
AND ((e.rule_type = 'phone'
|
||||||
|
AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
|
||||||
|
OR (e.rule_type = 'prefix'
|
||||||
|
AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
|
||||||
|
LIKE e.value || '%%')))
|
||||||
|
) AS can_call
|
||||||
|
FROM order_groups og
|
||||||
|
)
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
og.notification_status, og.delivery_link,
|
og.notification_status, og.delivery_link,
|
||||||
og.first_sms_sent_at, og.second_sms_sent_at,
|
og.first_sms_sent_at, og.second_sms_sent_at,
|
||||||
og.sms_sent_at, og.next_notification_check_at,
|
og.sms_sent_at, og.next_notification_check_at,
|
||||||
og.sms_attempts
|
og.sms_attempts,
|
||||||
|
c.last_contact_at, c.call_attempts, cl.can_call
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
|
JOIN contact c ON c.id = og.id
|
||||||
|
JOIN callable cl ON cl.id = og.id
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
AND (
|
-- Клиент уже выбрал дату/время — в ручную не переводим
|
||||||
-- 1. second_sms_sent + срок истёк
|
AND og.delivery_date IS NULL
|
||||||
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
-- Контакта не было — отсчитывать не от чего
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
|
AND c.last_contact_at > TIMESTAMP 'epoch'
|
||||||
-- 2. first_sms_sent + срок истёк (>3h после отправки)
|
-- Срок ожидания истёк от последнего контакта
|
||||||
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
|
AND NOW() - c.last_contact_at >= make_interval(hours => %s)
|
||||||
-- Иначе second_campaign не успеет отправить вторую SMS
|
-- Второе приглашение ждём, только если канал включён
|
||||||
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
|
AND (NOT %s OR og.second_sms_sent_at IS NOT NULL)
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
-- Звонок ждём, только если автодозвон включён и звонить можно
|
||||||
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
|
AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call)
|
||||||
AND og.second_sms_sent_at IS NOT NULL)
|
-- Гео-исключения — общие для SMS и ручного управления
|
||||||
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
AND NOT EXISTS (
|
||||||
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
SELECT 1 FROM sms_city_rules r
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
WHERE r.city = og.town
|
||||||
AND NOT EXISTS (
|
AND r.enabled = false
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
AND r.campaign_type IN ('*', 'manual')
|
||||||
WHERE scl.order_group_id = og.id
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
|
||||||
))
|
|
||||||
-- 4. sms_sending/checking + >3h без доставки (зависло)
|
|
||||||
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
|
|
||||||
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
|
|
||||||
)
|
)
|
||||||
ORDER BY og.created_at ASC
|
ORDER BY c.last_contact_at ASC
|
||||||
""")
|
""", (
|
||||||
|
int(manual_after_hours or 3),
|
||||||
|
bool(channels.get("second_sms")),
|
||||||
|
bool(channels.get("autocall")),
|
||||||
|
))
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def load_channels(conn):
|
||||||
|
"""Какие каналы связи сейчас включены в системе.
|
||||||
|
|
||||||
|
`first_sms` — первое приглашение, `second_sms` — повторное SMS,
|
||||||
|
`autocall` — робот-автодозвон (настройки кампании 'autocall_slot').
|
||||||
|
От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги.
|
||||||
|
"""
|
||||||
|
channels = {"first_sms": True, "second_sms": False, "autocall": False}
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT campaign_type, enabled
|
||||||
|
FROM sms_campaign_settings
|
||||||
|
WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot')
|
||||||
|
""")
|
||||||
|
for campaign_type, enabled in cur.fetchall():
|
||||||
|
if campaign_type == "autocall_slot":
|
||||||
|
channels["autocall"] = bool(enabled)
|
||||||
|
else:
|
||||||
|
channels[campaign_type] = bool(enabled)
|
||||||
|
except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS
|
||||||
|
pass
|
||||||
|
return channels
|
||||||
|
|
||||||
def update_order_group(conn, group_id, fields):
|
def update_order_group(conn, group_id, fields):
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
set_parts = []
|
set_parts = []
|
||||||
|
|
@ -156,35 +257,55 @@ def update_order_group(conn, group_id, fields):
|
||||||
|
|
||||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def step_move_to_manual(conn, settings):
|
def step_move_to_manual(conn, settings, channels=None):
|
||||||
"""Перевод групп в ручное управление"""
|
"""Перевод групп в ручное управление"""
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
delay_minutes = settings.get("auto_manual_after_hours", 3)
|
wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3)
|
||||||
|
channels = channels or load_channels(conn)
|
||||||
|
|
||||||
groups = get_groups_to_manual(conn)
|
groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours)
|
||||||
log.info(f"Step 1: {len(groups)} groups to move to manual")
|
log.info(
|
||||||
|
f"Step 1: {len(groups)} groups to move to manual "
|
||||||
|
f"(ожидание {wait_hours} ч от последнего контакта; каналы: "
|
||||||
|
f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, "
|
||||||
|
f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, "
|
||||||
|
f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})"
|
||||||
|
)
|
||||||
|
|
||||||
moved = 0
|
moved = 0
|
||||||
|
dry_run = os.environ.get("DRY_RUN") == "1"
|
||||||
for group in groups:
|
for group in groups:
|
||||||
group_id = str(group["id"])
|
group_id = str(group["id"])
|
||||||
name = group.get("customer_name") or group.get("group_key", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
phone = group.get("customer_phone", "")
|
phone = group.get("customer_phone", "")
|
||||||
delivery_link = group.get("delivery_link", "")
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
attempts = group.get("sms_attempts") or 1
|
||||||
|
|
||||||
log.info(f"Group {group_id}: moving to manual_required ({name})")
|
if dry_run:
|
||||||
|
log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})")
|
||||||
|
moved += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f"Group {group_id}: moving to manual_required ({name}); "
|
||||||
|
f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}"
|
||||||
|
)
|
||||||
|
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
"notification_status": "manual_required",
|
"notification_status": "manual_required",
|
||||||
"delivery_status": "manual_confirmation_required",
|
"delivery_status": "manual_confirmation_required",
|
||||||
"sms_attempts": 2,
|
# Попытки НЕ подменяем: показываем столько, сколько реально отправлено
|
||||||
|
"sms_attempts": attempts,
|
||||||
"last_sms_error": None,
|
"last_sms_error": None,
|
||||||
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
# Дальше ждать нечего — проверки по этому заказу закончились
|
||||||
|
"next_notification_check_at": None,
|
||||||
"status": "manual_required",
|
"status": "manual_required",
|
||||||
})
|
})
|
||||||
|
|
||||||
send_telegram(
|
send_telegram(
|
||||||
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
||||||
f"Клиент не согласовал доставку после двух SMS\n"
|
f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта "
|
||||||
|
f"({'звонки' if channels.get('autocall') else 'SMS'})\n"
|
||||||
f"Ссылка: {delivery_link}",
|
f"Ссылка: {delivery_link}",
|
||||||
tg_chat,
|
tg_chat,
|
||||||
)
|
)
|
||||||
|
|
@ -246,8 +367,9 @@ def main():
|
||||||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
log.info("RESTART: manual campaign — will process all eligible groups now")
|
log.info("RESTART: manual campaign — will process all eligible groups now")
|
||||||
else:
|
|
||||||
moved = step_move_to_manual(conn, settings)
|
channels = load_channels(conn)
|
||||||
|
moved = step_move_to_manual(conn, settings, channels=channels)
|
||||||
|
|
||||||
log.info(f"Run summary: moved_to_manual={moved}")
|
log.info(f"Run summary: moved_to_manual={moved}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,22 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
user=DB_USER, password=DB_PASS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_schedule_work_days(conn, settings):
|
||||||
|
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
|
||||||
|
|
||||||
|
Если график прочитать не удалось, остаются прежние дни кампании.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
days = row.get("sms_days") or []
|
||||||
|
if days:
|
||||||
|
settings["work_days"] = ",".join(str(int(d)) for d in days)
|
||||||
|
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
|
||||||
|
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
|
||||||
|
return settings
|
||||||
|
|
||||||
def load_settings(conn):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
|
@ -82,7 +98,8 @@ def load_settings(conn):
|
||||||
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
|
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
|
||||||
"work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
|
"work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
|
||||||
}
|
}
|
||||||
return dict(row)
|
settings = dict(row)
|
||||||
|
return apply_schedule_work_days(conn, settings)
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -183,6 +200,15 @@ def get_groups_to_send(conn):
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.delivery_status = 'paid_storage'
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
AND og.paid_storage_at IS NOT NULL
|
AND og.paid_storage_at IS NOT NULL
|
||||||
|
-- Гео-исключения — общие для SMS и звонков
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_rules r
|
||||||
|
WHERE r.city = og.town
|
||||||
|
AND r.enabled = false
|
||||||
|
AND r.campaign_type IN ('*', 'paid_storage')
|
||||||
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,22 @@ def get_db_conn():
|
||||||
user=DB_USER, password=DB_PASS,
|
user=DB_USER, password=DB_PASS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def apply_schedule_work_days(conn, settings):
|
||||||
|
"""Дни отправки — из «Графика работы» в ЛК (единый источник правды).
|
||||||
|
|
||||||
|
Если график прочитать не удалось, остаются прежние дни кампании.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
days = row.get("sms_days") or []
|
||||||
|
if days:
|
||||||
|
settings["work_days"] = ",".join(str(int(d)) for d in days)
|
||||||
|
except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика
|
||||||
|
print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}")
|
||||||
|
return settings
|
||||||
|
|
||||||
def load_settings(conn):
|
def load_settings(conn):
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
|
@ -104,7 +120,8 @@ def load_settings(conn):
|
||||||
"second_sms_delay_hours": 3,
|
"second_sms_delay_hours": 3,
|
||||||
"auto_manual_after_hours": 3,
|
"auto_manual_after_hours": 3,
|
||||||
}
|
}
|
||||||
return dict(row)
|
settings = dict(row)
|
||||||
|
return apply_schedule_work_days(conn, settings)
|
||||||
|
|
||||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -190,7 +207,7 @@ def send_telegram(message, chat_id):
|
||||||
|
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
def get_groups_to_send(conn, max_attempts):
|
||||||
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
||||||
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
||||||
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
|
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
|
||||||
|
|
@ -209,6 +226,15 @@ def get_groups_to_send(conn):
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
||||||
AND COALESCE(scp.second_sms_enabled, true) = true
|
AND COALESCE(scp.second_sms_enabled, true) = true
|
||||||
|
-- Гео-исключения — общие для SMS и звонков
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_rules r
|
||||||
|
WHERE r.city = og.town
|
||||||
|
AND r.enabled = false
|
||||||
|
AND r.campaign_type IN ('*', 'second_sms')
|
||||||
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
-- Нет активной второй SMS в логе
|
-- Нет активной второй SMS в логе
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
@ -217,8 +243,15 @@ def get_groups_to_send(conn):
|
||||||
AND scl.status IN ('sent', 'checking')
|
AND scl.status IN ('sent', 'checking')
|
||||||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
)
|
)
|
||||||
|
-- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку,
|
||||||
|
-- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны)
|
||||||
|
AND (
|
||||||
|
SELECT count(*) FROM sms_campaign_log scl2
|
||||||
|
WHERE scl2.order_group_id = og.id
|
||||||
|
AND scl2.campaign_type = 'second_sms'
|
||||||
|
) < %s
|
||||||
ORDER BY og.created_at ASC
|
ORDER BY og.created_at ASC
|
||||||
""")
|
""", (max_attempts,))
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_sms_to_check(conn, max_duration_min):
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
|
@ -301,6 +334,21 @@ def update_order_group(conn, group_id, fields):
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def count_sms_attempts(conn, group_id, campaign_type=CAMPAIGN_TYPE):
|
||||||
|
"""Сколько раз эту SMS уже пытались отправить (строк в логе кампании).
|
||||||
|
|
||||||
|
Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт
|
||||||
|
новую строку, поэтому в строке всегда было бы attempts=1, условие
|
||||||
|
«attempts < max_attempts» выполнялось бы вечно и вторая SMS уходила бы
|
||||||
|
каждые 5 минут бесконечно.
|
||||||
|
"""
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = %s
|
||||||
|
""", (group_id, campaign_type))
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
def is_within_work_hours(settings):
|
||||||
"""Проверка: сейчас рабочие часы.
|
"""Проверка: сейчас рабочие часы.
|
||||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||||
|
|
@ -328,8 +376,9 @@ def step_send_new(conn, settings):
|
||||||
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
||||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
|
||||||
groups = get_groups_to_send(conn)
|
groups = get_groups_to_send(conn, max_attempts)
|
||||||
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
|
@ -361,6 +410,8 @@ def step_send_new(conn, settings):
|
||||||
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
||||||
|
|
||||||
log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
|
log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
attempt_no = count_sms_attempts(conn, group_id) + 1
|
||||||
|
log.info(f"Group {group_id}: second SMS attempt {attempt_no}/{max_attempts}")
|
||||||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
if sms_id:
|
if sms_id:
|
||||||
|
|
@ -372,7 +423,7 @@ def step_send_new(conn, settings):
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="sent",
|
status="sent",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
||||||
update_order_group(conn, group_id, {
|
update_order_group(conn, group_id, {
|
||||||
|
|
@ -390,7 +441,7 @@ def step_send_new(conn, settings):
|
||||||
sms_text=sms_text,
|
sms_text=sms_text,
|
||||||
status="send_failed",
|
status="send_failed",
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=attempt_no,
|
||||||
error_message=error,
|
error_message=error,
|
||||||
)
|
)
|
||||||
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
||||||
|
|
@ -419,7 +470,7 @@ def step_check_status(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts") or 1
|
||||||
|
|
||||||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
||||||
|
|
@ -501,7 +552,7 @@ def step_handle_expired(conn, settings):
|
||||||
group_id = str(item["order_group_id"])
|
group_id = str(item["order_group_id"])
|
||||||
name = item.get("customer_name") or item.get("group_key", "—")
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
phone = item.get("customer_phone", "")
|
phone = item.get("customer_phone", "")
|
||||||
attempts = item.get("attempts", 1)
|
attempts = item.get("attempts") or 1
|
||||||
|
|
||||||
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
update_sms_log(conn, log_id, status="expired",
|
update_sms_log(conn, log_id, status="expired",
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,70 @@
|
||||||
/**
|
/**
|
||||||
* @file CallStatusBadge.jsx
|
* @file CallStatusBadge.jsx
|
||||||
* @description Бейдж коммуникационного статуса для строк реестра групп.
|
* @description Бейдж автодозвона в строке реестра доставок.
|
||||||
* Показывает проблему автодозвона снаружи, не открывая карточку.
|
*
|
||||||
* Приоритет сверху вниз — один бейдж наружу.
|
* Раньше наружу выходили две безликие плашки: «Автодозвон ✅» и «Автодозвон ❌» —
|
||||||
|
* по ним нельзя было понять, не дозвонились, не выбрал клиент день/время или заказ ушёл
|
||||||
|
* менеджеру. Теперь у каждого состояния своя подпись, взятая из
|
||||||
|
* AUTOCALL_STATUS_META (тот же список, что в фильтрах, — не разъезжается).
|
||||||
|
*
|
||||||
|
* «Не звонили» показываем только по заказам, которые ещё ждут решения
|
||||||
|
* (pending_confirmation / manual_confirmation_required): в уже доставленных
|
||||||
|
* заказах такая плашка — просто шум.
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// Приоритет: отказ > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота
|
import { AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
||||||
export const resolveCallBadge = (g) => {
|
|
||||||
if (!g) return null;
|
|
||||||
|
|
||||||
// 1. Отказ от звонков
|
/** Статусы, при которых заказ ещё ждёт решения клиента. */
|
||||||
if (g.autocall_opt_out) {
|
const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation_required"];
|
||||||
return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Слот от робота (информационный, самый низкий приоритет — показываем если нет проблем)
|
const STYLES = {
|
||||||
if (g.deliveryDateSource === "autocall") {
|
confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]",
|
||||||
return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" };
|
no_answer: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
|
||||||
}
|
no_input_day: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
|
||||||
|
no_input_half: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
|
||||||
|
no_input_both: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
|
||||||
|
manual: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]",
|
||||||
|
opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]",
|
||||||
|
excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||||
|
not_called: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||||
|
};
|
||||||
|
|
||||||
return null;
|
const TITLES = {
|
||||||
|
confirmed: "Клиент согласовал день и половину дня по звонку робота",
|
||||||
|
no_answer: "Робот звонил, трубку не взяли",
|
||||||
|
no_input_day: "Трубку взяли, но день клиент не выбрал",
|
||||||
|
no_input_half: "День клиент выбрал, а время — нет",
|
||||||
|
no_input_both: "В разных звонках клиент не выбрал ни день, ни время",
|
||||||
|
manual: "Нужен менеджер: неверный номер или ошибка звонка",
|
||||||
|
opt_out: "Звонки по этому клиенту запрещены вручную",
|
||||||
|
excluded: "Исключение: по этому номеру или городу не звоним",
|
||||||
|
not_called: "Автодозвона по заказу ещё не было",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ждёт ли заказ решения (тогда «не звонили» стоит показать). */
|
||||||
|
export const isAwaitingDecision = (group) => {
|
||||||
|
if (!group) return false;
|
||||||
|
const status = group.deliveryStatus || group.delivery_status || "";
|
||||||
|
return AWAITING_DELIVERY_STATUSES.includes(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Что показать в строке: null — показывать нечего. */
|
||||||
|
export const resolveCallBadge = (group) => {
|
||||||
|
if (!group) return null;
|
||||||
|
const status = group.autocallStatus || group.autocall_status || "not_called";
|
||||||
|
// Запрет и исключение важны всегда, независимо от стадии заказа.
|
||||||
|
if (status === "not_called" && !isAwaitingDecision(group)) return null;
|
||||||
|
const meta = AUTOCALL_STATUS_META[status];
|
||||||
|
if (!meta) return null;
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
label: `${meta.icon} ${meta.short}`,
|
||||||
|
cls: STYLES[status] || STYLES.not_called,
|
||||||
|
title: status === "excluded" && group.autocall_exclusion_reason
|
||||||
|
? `Исключение автодозвона: ${group.autocall_exclusion_reason}`
|
||||||
|
: TITLES[status] || meta.label,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CallStatusBadge = ({ group }) => {
|
export const CallStatusBadge = ({ group }) => {
|
||||||
|
|
|
||||||
|
|
@ -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],
|
deliveryDays: [1, 2, 3, 4, 5],
|
||||||
pickupDays: [1, 2, 3, 4, 5],
|
pickupDays: [1, 2, 3, 4, 5],
|
||||||
smsDays: [1, 2, 3, 4, 5],
|
smsDays: [1, 2, 3, 4, 5],
|
||||||
|
// Рабочие часы: склад (самовывоз) и доставка.
|
||||||
|
pickupOpen: "09:00",
|
||||||
|
pickupClose: "17:30",
|
||||||
|
pickupHalfEnd: "13:00",
|
||||||
|
pickupSameDayCutoff: "15:30",
|
||||||
|
deliveryOpen: "09:00",
|
||||||
|
deliveryClose: "21:00",
|
||||||
|
deliveryHalfEnd: "15:00",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TIME_FIELDS = [
|
||||||
|
"pickupOpen",
|
||||||
|
"pickupClose",
|
||||||
|
"pickupHalfEnd",
|
||||||
|
"pickupSameDayCutoff",
|
||||||
|
"deliveryOpen",
|
||||||
|
"deliveryClose",
|
||||||
|
"deliveryHalfEnd",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TimeField = ({ label, hint, value, onChange }) => (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="rounded-[14px] border-2 border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-2.5 text-sm font-semibold text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
{hint ? <span className="text-xs text-[var(--color-text-muted)]">{hint}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
|
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
|
||||||
const toggle = (num) => {
|
const toggle = (num) => {
|
||||||
if (selectedDays.includes(num)) {
|
if (selectedDays.includes(num)) {
|
||||||
|
|
@ -79,6 +112,13 @@ export const BusinessSchedulePanel = () => {
|
||||||
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
||||||
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
||||||
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
|
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
|
||||||
|
pickupOpen: data.pickupOpen || DEFAULT_SCHEDULE.pickupOpen,
|
||||||
|
pickupClose: data.pickupClose || DEFAULT_SCHEDULE.pickupClose,
|
||||||
|
pickupHalfEnd: data.pickupHalfEnd || DEFAULT_SCHEDULE.pickupHalfEnd,
|
||||||
|
pickupSameDayCutoff: data.pickupSameDayCutoff || DEFAULT_SCHEDULE.pickupSameDayCutoff,
|
||||||
|
deliveryOpen: data.deliveryOpen || DEFAULT_SCHEDULE.deliveryOpen,
|
||||||
|
deliveryClose: data.deliveryClose || DEFAULT_SCHEDULE.deliveryClose,
|
||||||
|
deliveryHalfEnd: data.deliveryHalfEnd || DEFAULT_SCHEDULE.deliveryHalfEnd,
|
||||||
};
|
};
|
||||||
setSchedule(loaded);
|
setSchedule(loaded);
|
||||||
setOriginal(loaded);
|
setOriginal(loaded);
|
||||||
|
|
@ -97,7 +137,8 @@ export const BusinessSchedulePanel = () => {
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
||||||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
|
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
|
||||||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
|
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays) ||
|
||||||
|
TIME_FIELDS.some((field) => schedule[field] !== original[field]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!hasChanges) return;
|
if (!hasChanges) return;
|
||||||
|
|
@ -115,6 +156,13 @@ export const BusinessSchedulePanel = () => {
|
||||||
p_delivery_days: schedule.deliveryDays,
|
p_delivery_days: schedule.deliveryDays,
|
||||||
p_pickup_days: schedule.pickupDays,
|
p_pickup_days: schedule.pickupDays,
|
||||||
p_sms_days: schedule.smsDays,
|
p_sms_days: schedule.smsDays,
|
||||||
|
p_pickup_open: schedule.pickupOpen,
|
||||||
|
p_pickup_close: schedule.pickupClose,
|
||||||
|
p_pickup_half_end: schedule.pickupHalfEnd,
|
||||||
|
p_pickup_same_day_cutoff: schedule.pickupSameDayCutoff,
|
||||||
|
p_delivery_open: schedule.deliveryOpen,
|
||||||
|
p_delivery_close: schedule.deliveryClose,
|
||||||
|
p_delivery_half_end: schedule.deliveryHalfEnd,
|
||||||
});
|
});
|
||||||
if (rpcError) throw rpcError;
|
if (rpcError) throw rpcError;
|
||||||
if (data?.ok) {
|
if (data?.ok) {
|
||||||
|
|
@ -153,6 +201,8 @@ export const BusinessSchedulePanel = () => {
|
||||||
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
||||||
|
Дни самовывоза — единый источник правды: их же использует страница клиента по ссылке
|
||||||
|
и робот-автодозвон, в другие дни самовывоз не предлагается.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{savedAt && !hasChanges && (
|
{savedAt && !hasChanges && (
|
||||||
|
|
@ -169,7 +219,8 @@ export const BusinessSchedulePanel = () => {
|
||||||
Доставка
|
Доставка
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
|
Дни, в которые клиент может выбрать доставку по ссылке. В остальные дни доставка
|
||||||
|
не предлагается.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,6 +231,26 @@ export const BusinessSchedulePanel = () => {
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<TimeField
|
||||||
|
label="Начало доставки"
|
||||||
|
hint="с этого времени возим заказы"
|
||||||
|
value={schedule.deliveryOpen}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, deliveryOpen: v }))}
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
label="Окончание доставки"
|
||||||
|
hint="позже не доставляем"
|
||||||
|
value={schedule.deliveryClose}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, deliveryClose: v }))}
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
label="Первая половина — до"
|
||||||
|
hint="граница между половинами дня"
|
||||||
|
value={schedule.deliveryHalfEnd}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, deliveryHalfEnd: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Pickup days */}
|
{/* Pickup days */}
|
||||||
|
|
@ -191,7 +262,8 @@ export const BusinessSchedulePanel = () => {
|
||||||
Самовывоз
|
Самовывоз
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
|
Дни, в которые склад выдаёт самовывоз. В остальные дни самовывоз не предлагается —
|
||||||
|
ни в ссылке клиента, ни в звонке робота.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,6 +274,32 @@ export const BusinessSchedulePanel = () => {
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<TimeField
|
||||||
|
label="Открытие склада"
|
||||||
|
hint="с этого времени выдаём заказы"
|
||||||
|
value={schedule.pickupOpen}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, pickupOpen: v }))}
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
label="Закрытие склада"
|
||||||
|
hint="позже заказы не выдаём"
|
||||||
|
value={schedule.pickupClose}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, pickupClose: v }))}
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
label="Первая половина — до"
|
||||||
|
hint="граница между половинами дня"
|
||||||
|
value={schedule.pickupHalfEnd}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, pickupHalfEnd: v }))}
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
label="Сегодня предлагаем до"
|
||||||
|
hint="позже робот и ссылка предложат только следующие дни — склад не успеет собрать заказ"
|
||||||
|
value={schedule.pickupSameDayCutoff}
|
||||||
|
onChange={(v) => setSchedule((s) => ({ ...s, pickupSameDayCutoff: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* SMS days */}
|
{/* SMS days */}
|
||||||
|
|
@ -213,7 +311,7 @@ export const BusinessSchedulePanel = () => {
|
||||||
SMS-приглашения
|
SMS-приглашения
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
|
Дни, в которые отправляются SMS-приглашения клиентам.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||||
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
||||||
|
import { resolveSmsBalance } from "../../services/smsBalance";
|
||||||
|
|
||||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -114,6 +115,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const [settings, setSettings] = useState(null);
|
const [settings, setSettings] = useState(null);
|
||||||
const [allSettings, setAllSettings] = useState({});
|
const [allSettings, setAllSettings] = useState({});
|
||||||
const [campaignCounts, setCampaignCounts] = useState({});
|
const [campaignCounts, setCampaignCounts] = useState({});
|
||||||
|
const [smsBalance, setSmsBalance] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
|
|
@ -145,6 +147,9 @@ export const SmsCampaignPanel = () => {
|
||||||
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
||||||
setAllSettings(settingsMap);
|
setAllSettings(settingsMap);
|
||||||
|
|
||||||
|
// Баланс — тот же живой источник, что на главной странице
|
||||||
|
resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal));
|
||||||
|
|
||||||
// Load counts per campaign (for cards)
|
// Load counts per campaign (for cards)
|
||||||
const { data: countsData, error: countsErr } = await supabase
|
const { data: countsData, error: countsErr } = await supabase
|
||||||
.from("sms_campaign_log")
|
.from("sms_campaign_log")
|
||||||
|
|
@ -317,17 +322,21 @@ export const SmsCampaignPanel = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
{/* Balance banner — тот же живой баланс, что на главной (один источник) */}
|
||||||
{/* Balance banner */}
|
{smsBalance?.balance != null && (
|
||||||
{allSettings.first_sms?.last_balance != null && (
|
|
||||||
<Panel className="p-3">
|
<Panel className="p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">💰</span>
|
<span className="text-2xl">💰</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||||||
<div className="text-lg font-bold text-[var(--color-text)]">
|
<div className="text-lg font-bold text-[var(--color-text)]">
|
||||||
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
{smsBalance.balance.toLocaleString("ru-RU")} ₽
|
||||||
</div>
|
</div>
|
||||||
|
{!smsBalance.live && (
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
показан баланс на момент последней рассылки — живой запрос не прошёл
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
@ -511,7 +520,7 @@ export const SmsCampaignPanel = () => {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
|
<SettingField label="В ручную работу через (часов после последнего контакта)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} hint="Отсчёт от последнего контакта: SMS или звонок робота. Если автодозвон включён — ждём звонок, если включено второе SMS — ждём его." />
|
||||||
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
|
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -804,7 +813,7 @@ export const SmsCampaignPanel = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const SettingField = ({ label, value, onChange }) => (
|
const SettingField = ({ label, value, onChange, hint }) => (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||||
<input
|
<input
|
||||||
|
|
@ -813,6 +822,7 @@ const SettingField = ({ label, value, onChange }) => (
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
{hint ? <span className="mt-1 block text-[10px] leading-snug text-[var(--color-text-muted)]">{hint}</span> : null}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ const OUTCOME_LABELS = {
|
||||||
confirmed_kept_previous: "Правка не доведена",
|
confirmed_kept_previous: "Правка не доведена",
|
||||||
confirmed_silent: "Молчание",
|
confirmed_silent: "Молчание",
|
||||||
no_answer: "Не взял",
|
no_answer: "Не взял",
|
||||||
no_input_day: "Не нажал (день)",
|
no_input_day: "Не выбрал день",
|
||||||
no_input_half: "Не нажал (время)",
|
no_input_half: "Не выбрал время",
|
||||||
operator: "Менеджер",
|
operator: "Менеджер",
|
||||||
opt_out: "Отказ",
|
opt_out: "Отказ",
|
||||||
invalid_number: "Неверный номер",
|
invalid_number: "Неверный номер",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { Badge } from "../../UI/Badge";
|
||||||
import { supabase } from "../../../supabaseClient";
|
import { supabase } from "../../../supabaseClient";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { TestCallPanel } from "./TestCallPanel";
|
import { TestCallPanel } from "./TestCallPanel";
|
||||||
|
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
|
||||||
|
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
|
||||||
|
|
||||||
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
// ── Кампании звонков ─────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -25,10 +27,8 @@ const OUTCOME_META = {
|
||||||
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
||||||
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
||||||
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
||||||
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
|
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
|
||||||
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
|
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
|
||||||
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
|
|
||||||
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
|
|
||||||
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
|
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
|
||||||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
@ -64,8 +64,8 @@ const slotText = (l) => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingField = ({ label, value, onChange }) => (
|
const SettingField = ({ label, value, onChange, hint }) => (
|
||||||
<label className="block">
|
<label className="block" title={hint || undefined}>
|
||||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -284,14 +284,40 @@ export const AutocallChannelTab = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Самовывоз — отдельный выключатель: звонки клиентам, которые забирают со склада */}
|
||||||
|
<div className={`mb-4 rounded-xl border p-3 ${settings.pickup_calls_enabled ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" : "border-[var(--color-border)] bg-[var(--color-surface)]"}`}>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-[var(--color-text)]">🏪 Самовывоз</div>
|
||||||
|
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{settings.pickup_calls_enabled
|
||||||
|
? "Робот звонит и по самовывозу: предлагает забрать завтра или послезавтра"
|
||||||
|
: "Звонки по самовывозу выключены — звоним только по доставке"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSetting("pickup_calls_enabled", !settings.pickup_calls_enabled)}
|
||||||
|
className={`relative h-7 w-12 shrink-0 rounded-full transition ${settings.pickup_calls_enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.pickup_calls_enabled ? "left-[22px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Интервалы и попытки */}
|
{/* Интервалы и попытки */}
|
||||||
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">📊 Интервалы и попытки</div>
|
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">📊 Интервалы и попытки</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<SettingField label="Пауза после SMS (мин)" value={settings.delay_after_sms_minutes} onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)} />
|
<SettingField
|
||||||
|
label="Пауза после SMS / попытки (мин)"
|
||||||
|
value={settings.delay_after_sms_minutes}
|
||||||
|
hint="Столько ждём и после отправленного SMS, и после последней неудачной попытки отправки: если приглашение не ушло, робот всё равно позвонит."
|
||||||
|
onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)}
|
||||||
|
/>
|
||||||
<SettingField label="Между попытками (мин)" value={settings.retry_interval_minutes} onChange={(v) => updateSetting("retry_interval_minutes", parseInt(v) || 120)} />
|
<SettingField label="Между попытками (мин)" value={settings.retry_interval_minutes} onChange={(v) => updateSetting("retry_interval_minutes", parseInt(v) || 120)} />
|
||||||
<SettingField label="Попыток в день" value={settings.attempts_per_day} onChange={(v) => updateSetting("attempts_per_day", parseInt(v) || 3)} />
|
<SettingField label="Попыток в день" value={settings.attempts_per_day} onChange={(v) => updateSetting("attempts_per_day", parseInt(v) || 3)} />
|
||||||
<SettingField label="Всего попыток" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 5)} />
|
<SettingField label="Всего попыток" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 5)} />
|
||||||
<SettingField label="«Не нажал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} />
|
<SettingField label="«Не выбрал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} />
|
||||||
<SettingField label="Попытки след. дня (HH:MM,HH:MM)" value={settings.next_day_attempt_times || ""} onChange={(v) => updateSetting("next_day_attempt_times", v)} />
|
<SettingField label="Попытки след. дня (HH:MM,HH:MM)" value={settings.next_day_attempt_times || ""} onChange={(v) => updateSetting("next_day_attempt_times", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -346,6 +372,12 @@ export const AutocallChannelTab = () => {
|
||||||
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
|
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
|
||||||
{isMegaAdmin && <TestCallPanel />}
|
{isMegaAdmin && <TestCallPanel />}
|
||||||
|
|
||||||
|
{/* ── Локации: где звонит робот ──────────────────────────────────── */}
|
||||||
|
<AutocallLocationsPanel />
|
||||||
|
|
||||||
|
{/* ── Исключения автодозвона ─────────────────────────────────────── */}
|
||||||
|
<AutocallExclusionsPanel />
|
||||||
|
|
||||||
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -404,6 +436,7 @@ export const AutocallChannelTab = () => {
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
{fmtPhone(l.customer_phone)}
|
{fmtPhone(l.customer_phone)}
|
||||||
{l.was_test_mode && <div className="text-[10px] text-[var(--color-warning)]">🧪 тест</div>}
|
{l.was_test_mode && <div className="text-[10px] text-[var(--color-warning)]">🧪 тест</div>}
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">{l.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.attempt_no}</div>
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.attempt_no}</div>
|
||||||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.duration_sec ? `${l.duration_sec}с` : "—"}</div>
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.duration_sec ? `${l.duration_sec}с` : "—"}</div>
|
||||||
|
|
|
||||||
|
|
@ -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_kept_previous: { label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
||||||
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
||||||
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
||||||
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
|
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
|
||||||
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
|
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
|
||||||
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
|
|
||||||
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
|
|
||||||
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
|
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
|
||||||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
@ -49,6 +47,7 @@ const fmtTime = (ts) => {
|
||||||
|
|
||||||
export const TestCallPanel = () => {
|
export const TestCallPanel = () => {
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
|
const [callType, setCallType] = useState("dlv"); // dlv = доставка, pkp = самовывоз
|
||||||
const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error
|
const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error
|
||||||
const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста
|
const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста
|
||||||
const [startedAt, setStartedAt] = useState(null);
|
const [startedAt, setStartedAt] = useState(null);
|
||||||
|
|
@ -90,6 +89,7 @@ export const TestCallPanel = () => {
|
||||||
order_group_id: null,
|
order_group_id: null,
|
||||||
phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)),
|
phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)),
|
||||||
scenario: "A",
|
scenario: "A",
|
||||||
|
call_type: callType,
|
||||||
is_test_call: true,
|
is_test_call: true,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
@ -152,6 +152,29 @@ export const TestCallPanel = () => {
|
||||||
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
|
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Тип сценария: доставка или самовывоз — от него зависит озвучка робота */}
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">Сценарий:</span>
|
||||||
|
{[
|
||||||
|
{ key: "dlv", label: "🚚 Доставка" },
|
||||||
|
{ key: "pkp", label: "🏪 Самовывоз" },
|
||||||
|
].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCallType(t.key)}
|
||||||
|
disabled={status === "queueing" || status === "queued" || status === "dialing"}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition disabled:opacity-50 ${
|
||||||
|
callType === t.key
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
|
|
@ -227,6 +250,7 @@ export const TestCallPanel = () => {
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
|
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
|
||||||
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
|
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">{t.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</span>
|
||||||
<span className={`text-[var(--color-text-muted)]`}>{tm.icon} {tm.label}</span>
|
<span className={`text-[var(--color-text-muted)]`}>{tm.icon} {tm.label}</span>
|
||||||
{slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>}
|
{slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>}
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,26 @@ const getCrimeaHour = (referenceDate = new Date()) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Минуты от полуночи по Симферополю — чтобы сравнивать с настройками склада.
|
||||||
|
const getCrimeaMinutes = (referenceDate = new Date()) => {
|
||||||
|
const parts = new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
timeZone: DELIVERY_TIMEZONE,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(referenceDate);
|
||||||
|
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0", 10);
|
||||||
|
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0", 10);
|
||||||
|
return hour * 60 + minute;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "15:30" → 930
|
||||||
|
const timeToMinutes = (value, fallback) => {
|
||||||
|
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
|
||||||
|
if (!match) return fallback;
|
||||||
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
};
|
||||||
|
|
||||||
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
if (!dateKey) return false;
|
if (!dateKey) return false;
|
||||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
|
|
@ -53,64 +73,67 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
|
/**
|
||||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
* Слоты самовывоза.
|
||||||
const hour = getCrimeaHour(referenceDate);
|
*
|
||||||
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
|
* Окно считаем от дня готовности (referenceDate — день попадания в базу, он же
|
||||||
|
* день отправки ссылки): забрать можно в день готовности и два следующих дня,
|
||||||
|
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
|
||||||
|
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
|
||||||
|
*/
|
||||||
|
export const getPickupSlots = (
|
||||||
|
referenceDate = new Date(),
|
||||||
|
pickupDays = [1, 2, 3, 4, 5],
|
||||||
|
now = new Date(),
|
||||||
|
schedule = {},
|
||||||
|
) => {
|
||||||
|
const todayKey = getCrimeaTodayKey(now);
|
||||||
|
const readyKey = getCrimeaTodayKey(referenceDate);
|
||||||
|
// Часы склада из админки («График работы»): граница половины дня и отсечка
|
||||||
|
// «сегодня». Дефолты — прежнее поведение (12:00 и «весь день»).
|
||||||
|
const halfEndMinutes = timeToMinutes(schedule.halfEnd, 12 * 60);
|
||||||
|
const cutoffMinutes = timeToMinutes(schedule.sameDayCutoff, 24 * 60);
|
||||||
|
const nowMinutes = getCrimeaMinutes(now);
|
||||||
|
// Окно считается рабочими днями: день готовности + два следующих рабочих дня
|
||||||
|
// (пятница → пт, пн, вт). Прошедшие дни не показываем.
|
||||||
|
const windowKeys = [];
|
||||||
|
let windowDay = readyKey;
|
||||||
|
let guard = 0;
|
||||||
|
while (windowKeys.length < 3 && guard < 30) {
|
||||||
|
if (isAllowedPickupDay(windowDay, pickupDays)) windowKeys.push(windowDay);
|
||||||
|
windowDay = addDaysKey(windowDay, 1);
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
const allowedKeys = windowKeys.filter((key) => key >= todayKey);
|
||||||
|
const PICKUP_TYPES = ["today", "tomorrow", "dayAfter"];
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
if (isTodayWorkday) {
|
const addSlot = (dateKey, half, label, pickupType) => {
|
||||||
if (hour < 12) {
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${todayKey}-first`,
|
|
||||||
date: todayKey,
|
|
||||||
time: "Первая половина дня",
|
|
||||||
label: "Сегодня",
|
|
||||||
pickupType: "today",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${todayKey}-second`,
|
id: `pickup-${dateKey}-${half}`,
|
||||||
date: todayKey,
|
date: dateKey,
|
||||||
time: "Вторая половина дня",
|
time: half === "first" ? "Первая половина дня" : "Вторая половина дня",
|
||||||
label: "Сегодня",
|
label,
|
||||||
pickupType: "today",
|
pickupType,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
allowedKeys.forEach((dateKey, index) => {
|
||||||
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
const isToday = dateKey === todayKey;
|
||||||
slots.push({
|
const label = isToday
|
||||||
id: `pickup-${tomorrowWorkday}-first`,
|
? "Сегодня"
|
||||||
date: tomorrowWorkday,
|
: (getDeliveryRelativeDayLabel(dateKey, now) || `День ${index + 1}`);
|
||||||
time: "Первая половина дня",
|
if (isToday) {
|
||||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
// После отсечки «сегодня» склад не успеет собрать заказ — вариант не показываем,
|
||||||
pickupType: "tomorrow",
|
// первая половина доступна только до границы половины дня.
|
||||||
});
|
if (nowMinutes >= cutoffMinutes) return;
|
||||||
slots.push({
|
if (nowMinutes < halfEndMinutes) addSlot(dateKey, "first", label, "today");
|
||||||
id: `pickup-${tomorrowWorkday}-second`,
|
addSlot(dateKey, "second", label, "today");
|
||||||
date: tomorrowWorkday,
|
return;
|
||||||
time: "Вторая половина дня",
|
}
|
||||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
addSlot(dateKey, "first", label, PICKUP_TYPES[index] || "dayAfter");
|
||||||
pickupType: "tomorrow",
|
addSlot(dateKey, "second", label, PICKUP_TYPES[index] || "dayAfter");
|
||||||
});
|
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
|
||||||
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${dayAfterWorkday}-first`,
|
|
||||||
date: dayAfterWorkday,
|
|
||||||
time: "Первая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
|
|
||||||
pickupType: "dayAfter",
|
|
||||||
});
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${dayAfterWorkday}-second`,
|
|
||||||
date: dayAfterWorkday,
|
|
||||||
time: "Вторая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
|
|
||||||
pickupType: "dayAfter",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
|
|
@ -156,8 +179,14 @@ export const PickupSlotsPicker = ({
|
||||||
selectedSlotId,
|
selectedSlotId,
|
||||||
referenceDate = new Date(),
|
referenceDate = new Date(),
|
||||||
pickupDays = [1, 2, 3, 4, 5],
|
pickupDays = [1, 2, 3, 4, 5],
|
||||||
|
schedule = {},
|
||||||
}) => {
|
}) => {
|
||||||
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
|
const halfEnd = schedule?.halfEnd;
|
||||||
|
const sameDayCutoff = schedule?.sameDayCutoff;
|
||||||
|
const slots = React.useMemo(
|
||||||
|
() => getPickupSlots(referenceDate, pickupDays, new Date(), { halfEnd, sameDayCutoff }),
|
||||||
|
[referenceDate, pickupDays, halfEnd, sameDayCutoff],
|
||||||
|
);
|
||||||
|
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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 { SkeletonPage } from "../UI/Loading";
|
||||||
import { Pagination } from "../UI/Pagination";
|
import { Pagination } from "../UI/Pagination";
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
|
import { SmsProblemNotice } from "./SmsProblemNotice";
|
||||||
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
|
@ -299,6 +300,7 @@ const isFilterActive = (filters) => {
|
||||||
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
|
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
|
||||||
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
|
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
|
||||||
if (filters.smsStatus && filters.smsStatus !== "all") return true;
|
if (filters.smsStatus && filters.smsStatus !== "all") return true;
|
||||||
|
if (filters.autocallStatus && filters.autocallStatus !== "all") return true;
|
||||||
if (filters.dateFrom) return true;
|
if (filters.dateFrom) return true;
|
||||||
if (filters.dateTo) return true;
|
if (filters.dateTo) return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -404,7 +406,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
|
||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.dateFrom, filters.dateTo]);
|
}, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.autocallStatus, filters.dateFrom, filters.dateTo]);
|
||||||
|
|
||||||
const totalGroups = rankedGroups.length;
|
const totalGroups = rankedGroups.length;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));
|
||||||
|
|
@ -508,6 +510,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Причина, по которой SMS не уходят — до таблицы, чтобы не искать её в логе */}
|
||||||
|
<SmsProblemNotice orderGroupIds={filteredGroups.map((g) => g.id)} />
|
||||||
|
|
||||||
{!totalGroups ? (
|
{!totalGroups ? (
|
||||||
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
|
|
|
||||||
|
|
@ -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 Блок «Автодозвон» в карточке заказа.
|
* @description Блок «Автодозвон» в карточке заказа.
|
||||||
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
|
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
|
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
|
||||||
|
import { describeCallProcess, isOrderAgreed } from "../../services/autocallProcess";
|
||||||
|
import { isAutocallPossible } from "../../services/autocallAvailability";
|
||||||
|
|
||||||
const OUTCOME_META = {
|
const OUTCOME_META = {
|
||||||
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
|
||||||
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
|
||||||
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
|
||||||
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
|
||||||
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
|
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
|
||||||
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
|
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
|
||||||
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
|
|
||||||
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
|
|
||||||
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
|
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
|
||||||
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
|
||||||
};
|
};
|
||||||
|
|
@ -43,13 +44,16 @@ const slotText = (l) => {
|
||||||
|
|
||||||
export const AutocallStatusCard = ({ order, userRole }) => {
|
export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
const [calls, setCalls] = useState([]);
|
const [calls, setCalls] = useState([]);
|
||||||
|
const [queueItem, setQueueItem] = useState(null);
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
const [queued, setQueued] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
const canManage = ["mega_admin", "logistician"].includes(userRole);
|
const canManage = ["mega_admin", "logistician"].includes(userRole);
|
||||||
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
|
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
|
||||||
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
|
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
|
||||||
|
const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null;
|
||||||
|
|
||||||
const loadCalls = useCallback(async () => {
|
const loadCalls = useCallback(async () => {
|
||||||
if (!order?.id) return;
|
if (!order?.id) return;
|
||||||
|
|
@ -60,7 +64,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("order_group_id", order.id)
|
.eq("order_group_id", order.id)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(5);
|
.limit(10);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setCalls(data || []);
|
setCalls(data || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -70,23 +74,147 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
}
|
}
|
||||||
}, [order?.id]);
|
}, [order?.id]);
|
||||||
|
|
||||||
useEffect(() => { loadCalls(); }, [loadCalls]);
|
// Последняя заявка «Позвонить сейчас» по этому заказу: без неё в карточке было
|
||||||
|
// не видно, ушёл ли запрос и что с ним стало.
|
||||||
|
const loadQueue = useCallback(async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("autocall_queue")
|
||||||
|
.select("id, requested_at, processed_at, is_test_call, call_type, phone")
|
||||||
|
.eq("order_group_id", order.id)
|
||||||
|
.order("requested_at", { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
setQueueItem(data?.[0] || null);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}, [order?.id]);
|
||||||
|
|
||||||
|
// Настройки кампании: рабочие часы/дни и тестовый режим — из них понятно,
|
||||||
|
// когда именно робот возьмёт заявку.
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.select("enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days")
|
||||||
|
.eq("campaign_type", "autocall_slot")
|
||||||
|
.maybeSingle();
|
||||||
|
setSettings(data || null);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCalls();
|
||||||
|
loadQueue();
|
||||||
|
loadSettings();
|
||||||
|
}, [loadCalls, loadQueue, loadSettings]);
|
||||||
|
|
||||||
|
// Звонок, который робот сделал по нашей заявке (в пределах 15 минут после неё).
|
||||||
|
const callAfter = useMemo(() => {
|
||||||
|
if (!queueItem?.requested_at) return null;
|
||||||
|
const from = new Date(queueItem.requested_at).getTime() - 1000;
|
||||||
|
const to = from + 15 * 60 * 1000;
|
||||||
|
return (
|
||||||
|
calls.find((c) => {
|
||||||
|
const at = new Date(c.created_at).getTime();
|
||||||
|
return at >= from && at <= to;
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}, [calls, queueItem]);
|
||||||
|
|
||||||
|
// Заявка свежая (сутки) — иначе в карточке висел бы вчерашний процесс.
|
||||||
|
const recentQueue = useMemo(() => {
|
||||||
|
if (!queueItem?.requested_at) return null;
|
||||||
|
return Date.now() - new Date(queueItem.requested_at).getTime() <= 24 * 60 * 60 * 1000 ? queueItem : null;
|
||||||
|
}, [queueItem]);
|
||||||
|
|
||||||
|
// Заявка висит непринятой больше часа в рабочее время — планировщик молча
|
||||||
|
// пропускает её (окно звонка закрыто: по графику роботу нечего предлагать).
|
||||||
|
// Показываем честную причину вместо «в очереди».
|
||||||
|
const callUnreachable = useMemo(() => {
|
||||||
|
if (!recentQueue || recentQueue.processed_at || callAfter) return false;
|
||||||
|
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
|
||||||
|
if (age < 60 * 60 * 1000) return false;
|
||||||
|
// Рабочее время сейчас?
|
||||||
|
const workDays = (settings?.work_days || "").toString().split(",").map((s) => parseInt(s, 10)).filter(Number.isFinite);
|
||||||
|
const startHour = Number(settings?.work_hours_start ?? 9);
|
||||||
|
const endHour = Number(settings?.work_hours_end ?? 20);
|
||||||
|
const msk = new Date(Date.now() + (3 * 60 + new Date().getTimezoneOffset()) * 60000);
|
||||||
|
const dayOk = workDays.length === 0 || workDays.includes(msk.getDay());
|
||||||
|
const hourOk = msk.getHours() >= startHour && msk.getHours() < endHour;
|
||||||
|
return dayOk && hourOk;
|
||||||
|
}, [recentQueue, callAfter, settings]);
|
||||||
|
|
||||||
|
// Можно ли вообще выбрать автодозвон по этому заказу? Считаем по окну
|
||||||
|
// звонка (те же правила, что у планировщика). Пока окно закрыто — кнопку
|
||||||
|
// блокируем с объяснением; заявку в очереди НЕ трогаем и НЕ закрываем.
|
||||||
|
const [availability, setAvailability] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
isAutocallPossible(order).then((res) => {
|
||||||
|
if (!cancelled) setAvailability(res);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [order?.id, order?.delivery_type, order?.created_at]);
|
||||||
|
const autocallBlocked = availability ? !availability.possible : false;
|
||||||
|
|
||||||
|
// Заказ уже согласован — дату выбрал клиент (в том числе в звонке) или поставил менеджер.
|
||||||
|
// 'manual_required' («нужен менеджер») сюда НЕ входит: там дату как раз не выбрали,
|
||||||
|
// и звонок робота допустим (см. isOrderAgreed и её тесты).
|
||||||
|
const orderAgreed = isOrderAgreed(order);
|
||||||
|
|
||||||
|
const process = useMemo(
|
||||||
|
() => describeCallProcess({
|
||||||
|
queueItem: recentQueue,
|
||||||
|
callAfter,
|
||||||
|
settings: {
|
||||||
|
...(settings || {}),
|
||||||
|
call_unreachable: callUnreachable,
|
||||||
|
order_agreed: orderAgreed,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[recentQueue, callAfter, settings, callUnreachable, orderAgreed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing";
|
||||||
|
|
||||||
|
// Пока заявку не отработали (и в пределах 15 минут) — подтягиваем состояние каждые 7 секунд.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processing || !recentQueue) return undefined;
|
||||||
|
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
|
||||||
|
if (age > 15 * 60 * 1000) return undefined;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
loadQueue();
|
||||||
|
loadCalls();
|
||||||
|
}, 7000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [processing, recentQueue, loadQueue, loadCalls]);
|
||||||
|
|
||||||
const handleCallNow = async () => {
|
const handleCallNow = async () => {
|
||||||
if (!order?.id) return;
|
if (!order?.id) return;
|
||||||
setQueued(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn)
|
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn).
|
||||||
|
// Сценарий планировщик определит сам по дню добавления группы
|
||||||
|
// (день добавления → A: завтра/послезавтра; позже → B: только завтра).
|
||||||
const { error } = await supabase.from("autocall_queue").insert({
|
const { error } = await supabase.from("autocall_queue").insert({
|
||||||
order_group_id: order.id,
|
order_group_id: order.id,
|
||||||
phone: order.customer_phone || order.customerPhone || "",
|
phone: order.customer_phone || order.customerPhone || "",
|
||||||
scenario: "A",
|
// Тип озвучки: по самовывозу робот говорит «забрать», по доставке — «доставить».
|
||||||
|
// Если тип в объекте не пришёл, планировщик определит его по заказу сам.
|
||||||
|
...((order.delivery_type || order.deliveryType)
|
||||||
|
? { call_type: (order.delivery_type || order.deliveryType) === "pickup" ? "pkp" : "dlv" }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setTimeout(() => setQueued(false), 3000);
|
await loadQueue();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Queue call error:", e);
|
console.error("Queue call error:", e);
|
||||||
setQueued(false);
|
} finally {
|
||||||
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -112,41 +240,102 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
|
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
|
||||||
const lastSlot = lastCall ? slotText(lastCall) : null;
|
const lastSlot = lastCall ? slotText(lastCall) : null;
|
||||||
|
|
||||||
|
// Единый человеческий статус (по аналогии со статусом SMS)
|
||||||
|
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
|
||||||
|
const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Итог */}
|
{/* Единый статус — как у SMS */}
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
{optOut ? (
|
<Badge tone={statusMeta.tone}>{statusMeta.label}</Badge>
|
||||||
<Badge tone="danger">🚫 Отказ от звонков</Badge>
|
|
||||||
) : isAutocall ? (
|
|
||||||
<Badge tone="accent">🤖 Согласовано роботом</Badge>
|
|
||||||
) : calls.length > 0 ? (
|
|
||||||
<Badge tone={lastMeta?.tone || "neutral"}>{lastMeta?.icon} {lastMeta?.label}</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge tone="neutral">Не звонили</Badge>
|
|
||||||
)}
|
|
||||||
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
|
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Попытки */}
|
{/* Пояснение исключения */}
|
||||||
|
{exclusionReason && !optOut && (
|
||||||
|
<div className="mb-3 rounded-lg bg-[rgba(191,123,33,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
|
||||||
|
🚫 Робот не звонит на этот номер по настройке: {exclusionReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Счётчик попыток — как у SMS «Попыток отправки: N» */}
|
||||||
|
{calls.length > 0 && (
|
||||||
|
<div className="mb-2 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Попыток звонков: {calls.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Состояние ручного звонка: заявка, набор, результат, «когда позвонят» */}
|
||||||
|
{process && (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"mb-3 rounded-lg px-2.5 py-1.5 text-[11px]",
|
||||||
|
process.tone === "danger"
|
||||||
|
? "bg-[rgba(239,68,68,0.08)] text-[var(--color-danger)]"
|
||||||
|
: process.tone === "warning"
|
||||||
|
? "bg-[rgba(245,158,11,0.08)] text-[var(--color-warning)]"
|
||||||
|
: process.tone === "accent"
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||||
|
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
{processing && (
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{process.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[10px] opacity-80">{process.hint}</div>
|
||||||
|
{process.phase === "done" && callAfter && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
|
||||||
|
{(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[10px] opacity-80">{fmtTime(callAfter.created_at)}</span>
|
||||||
|
{slotText(callAfter) && <span className="text-[10px] opacity-80">{slotText(callAfter)}</span>}
|
||||||
|
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* История звонков — как «История SMS» */}
|
||||||
{calls.length > 0 ? (
|
{calls.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<div className="mt-1 border-t border-[var(--color-border)] pt-3">
|
||||||
{calls.map((c) => {
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div>
|
||||||
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
{loading && <div className="mb-1 text-[10px] text-[var(--color-text-muted)]">обновляем…</div>}
|
||||||
const slot = slotText(c);
|
<div className="space-y-1.5">
|
||||||
return (
|
{calls.map((c, idx) => {
|
||||||
<div key={c.id} className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px]">
|
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
||||||
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
|
const slot = slotText(c);
|
||||||
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
|
const attemptNo = calls.length - idx;
|
||||||
{slot && <span className="text-[var(--color-text)]">{slot}</span>}
|
return (
|
||||||
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
|
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
|
||||||
</div>
|
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||||
);
|
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
|
||||||
})}
|
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
|
||||||
|
{c.call_type === "pkp" && <span className="text-[10px] text-[var(--color-text-muted)]">🏪 самовывоз</span>}
|
||||||
|
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
|
||||||
|
</div>
|
||||||
|
{(slot || c.attempt_no > 1 || c.duration_sec > 0) && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{c.attempt_no > 1 && <span>Попытка {c.attempt_no}</span>}
|
||||||
|
{slot && <span>Выбрано: {slot}</span>}
|
||||||
|
{c.duration_sec > 0 && <span>{Math.round(c.duration_sec)} сек</span>}
|
||||||
|
{c.scenario === "B" && <span>только завтра</span>}
|
||||||
|
{c.scenario === "A" && <span>завтра/послезавтра</span>}
|
||||||
|
{c.changed && <span className="text-[var(--color-warning)]">клиент правил выбор</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
|
<div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
|
||||||
|
|
@ -161,23 +350,48 @@ export const AutocallStatusCard = ({ order, userRole }) => {
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||||
<button
|
{autocallBlocked && !orderAgreed && (
|
||||||
type="button"
|
<div className="mb-2 rounded-lg bg-[rgba(245,158,11,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
|
||||||
onClick={handleCallNow}
|
🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами.
|
||||||
disabled={queued || optOut}
|
</div>
|
||||||
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
|
)}
|
||||||
>
|
{orderAgreed && (
|
||||||
{queued ? "✓ В очереди" : "📞 Позвонить сейчас"}
|
<div className="mb-2 rounded-lg bg-[var(--color-surface-strong)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
</button>
|
Заказ уже согласован — автодозвон не нужен. Если надо переспросить клиента, позвоните сами.
|
||||||
<button
|
</div>
|
||||||
type="button"
|
)}
|
||||||
onClick={handleToggleOptOut}
|
<div className="flex gap-2">
|
||||||
disabled={toggling}
|
<button
|
||||||
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
|
type="button"
|
||||||
>
|
onClick={handleCallNow}
|
||||||
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
|
disabled={sending || processing || optOut || autocallBlocked || orderAgreed}
|
||||||
</button>
|
title={
|
||||||
|
orderAgreed
|
||||||
|
? "Заказ уже согласован — звонок робота не нужен"
|
||||||
|
: autocallBlocked
|
||||||
|
? (availability?.reason || "Автодозвон недоступен")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending
|
||||||
|
? "Отправляем заявку…"
|
||||||
|
: process?.phase === "dialing"
|
||||||
|
? "📞 Звоним…"
|
||||||
|
: processing
|
||||||
|
? "✓ Заявка в очереди"
|
||||||
|
: "📞 Позвонить сейчас"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleOptOut}
|
||||||
|
disabled={toggling}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -689,11 +689,21 @@ export const OrderDetailPanel = ({
|
||||||
// ---- Drag-and-drop block reordering state ----
|
// ---- Drag-and-drop block reordering state ----
|
||||||
const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole);
|
const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole);
|
||||||
const [blockOrder, setBlockOrder] = React.useState(() => {
|
const [blockOrder, setBlockOrder] = React.useState(() => {
|
||||||
|
let saved = null;
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
|
const parsed = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
|
||||||
if (Array.isArray(saved) && saved.length > 0) return saved;
|
if (Array.isArray(parsed) && parsed.length > 0) saved = parsed;
|
||||||
} catch {}
|
} catch {}
|
||||||
return DEFAULT_BLOCK_ORDER;
|
if (!saved) return DEFAULT_BLOCK_ORDER;
|
||||||
|
// Мердж с дефолтом: новые блоки (например, autocall_status) добавляются,
|
||||||
|
// устаревшие ключи удаляются — иначе сохранённый порядок навсегда скрывает новые блоки.
|
||||||
|
const merged = [
|
||||||
|
// сначала сохранённые ключи, которые ещё актуальны
|
||||||
|
...saved.filter((k) => DEFAULT_BLOCK_ORDER.includes(k)),
|
||||||
|
// затем новые блоки, которых нет в сохранённом — вставляем рядом со «Статус SMS»
|
||||||
|
...DEFAULT_BLOCK_ORDER.filter((k) => !saved.includes(k)),
|
||||||
|
];
|
||||||
|
return merged;
|
||||||
});
|
});
|
||||||
const [activeTab, setActiveTab] = React.useState(() => {
|
const [activeTab, setActiveTab] = React.useState(() => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import React from "react";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { DatePicker } from "../UI/DatePicker";
|
import { DateRangePicker } from "../UI/DateRangePicker";
|
||||||
import { Switch } from "../UI/Switch";
|
import { Switch } from "../UI/Switch";
|
||||||
|
import { AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews";
|
||||||
|
|
||||||
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
|
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
|
@ -121,10 +122,17 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
{ value: "pickup", label: "Самовывоз" },
|
{ value: "pickup", label: "Самовывоз" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const smsProblemOptions = [
|
const smsStatusOptions = [
|
||||||
{ value: "all", label: "Проблемы SMS" },
|
{ value: "all", label: "Статус SMS" },
|
||||||
{ value: "problem_not_sent", label: "SMS не отправлено" },
|
{ value: "not_sent", label: "Не отправлено" },
|
||||||
{ value: "problem_not_delivered", label: "SMS не доставлено" },
|
{ value: "sent", label: "Отправлено" },
|
||||||
|
{ value: "delivered", label: "Доставлено" },
|
||||||
|
{ value: "error", label: "Ошибка" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const autocallStatusOptions = [
|
||||||
|
{ value: "all", label: "Статус автодозвона" },
|
||||||
|
...AUTOCALL_STATUS_OPTIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -205,29 +213,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Date range + SMS problems (width matches its text) + hide-completed toggle.
|
{/* Row 2: Период + Проблемы SMS + Прозвонить + hide-completed toggle.
|
||||||
Align by the bottom edge of the input fields, not by center — dates have a
|
Выравнивание по верхней линии полей ввода. */}
|
||||||
label above the field, so items-center would lift SMS/toggle above them. */}
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<DateRangePicker
|
||||||
<DatePicker
|
valueFrom={filters.dateFrom || ""}
|
||||||
value={filters.dateFrom || ""}
|
valueTo={filters.dateTo || ""}
|
||||||
onChange={(v) => updateFilter("dateFrom", v)}
|
onChange={(from, to) => {
|
||||||
placeholder="С даты"
|
setFilters((current) => ({ ...current, dateFrom: from, dateTo: to }));
|
||||||
label="Период: с"
|
}}
|
||||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
className="min-w-[180px] flex-1 md:max-w-[240px]"
|
||||||
/>
|
|
||||||
<DatePicker
|
|
||||||
value={filters.dateTo || ""}
|
|
||||||
onChange={(v) => updateFilter("dateTo", v)}
|
|
||||||
placeholder="По дату"
|
|
||||||
label="по"
|
|
||||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
|
||||||
/>
|
/>
|
||||||
{hasDateFilter && (
|
{hasDateFilter && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearDateFilter}
|
onClick={clearDateFilter}
|
||||||
className="mb-0.5 h-[46px] gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)]"
|
className="h-[46px] gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)]"
|
||||||
>
|
>
|
||||||
✕ Сбросить
|
✕ Сбросить
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -235,9 +236,16 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
|
|
||||||
<DropdownSelect
|
<DropdownSelect
|
||||||
value={filters.smsStatus || "all"}
|
value={filters.smsStatus || "all"}
|
||||||
options={smsProblemOptions}
|
options={smsStatusOptions}
|
||||||
onChange={(v) => updateFilter("smsStatus", v)}
|
onChange={(v) => updateFilter("smsStatus", v)}
|
||||||
placeholder="Проблемы SMS"
|
placeholder="Статус SMS"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
value={filters.autocallStatus || "all"}
|
||||||
|
options={autocallStatusOptions}
|
||||||
|
onChange={(v) => updateFilter("autocallStatus", v)}
|
||||||
|
placeholder="Статус автодозвона"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{typeof onToggleHideCompleted === "function" && (
|
{typeof onToggleHideCompleted === "function" && (
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,30 @@ const SMS_STATUS_LABELS = {
|
||||||
pending: "в очереди",
|
pending: "в очереди",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Автодозвон: outcome → человеческий статус ─────────────────────
|
||||||
|
const AUTOCALL_OUTCOME_LABELS = {
|
||||||
|
confirmed: { label: "Автодозвон: клиент согласовал слот", tone: "ok", icon: "🤖" },
|
||||||
|
confirmed_silent: { label: "Автодозвон: подтвердил молчанием", tone: "ok", icon: "🤖" },
|
||||||
|
confirmed_kept_previous:{ label: "Автодозвон: клиент правил выбор", tone: "warning", icon: "🤖" },
|
||||||
|
no_answer: { label: "Автодозвон: не взял трубку", tone: "danger", icon: "🤖" },
|
||||||
|
no_input_day: { label: "Автодозвон: не выбрал день", tone: "warning", icon: "🤖" },
|
||||||
|
no_input_half: { label: "Автодозвон: не выбрал время", tone: "warning", icon: "🤖" },
|
||||||
|
invalid_number: { label: "Автодозвон: неверный номер", tone: "danger", icon: "🤖" },
|
||||||
|
error: { label: "Автодозвон: ошибка звонка", tone: "danger", icon: "🤖" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTOCALL_DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
|
||||||
|
const AUTOCALL_HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
|
||||||
|
|
||||||
|
const autocallSlotText = (l) => {
|
||||||
|
if (!l) return null;
|
||||||
|
const day = AUTOCALL_DAY_LABEL[l.dtmf_day];
|
||||||
|
const half = AUTOCALL_HALF_LABEL[l.dtmf_half];
|
||||||
|
if (day && half) return `${day}, ${half}`;
|
||||||
|
if (day) return day;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const fmtTimestamp = (ts) => {
|
const fmtTimestamp = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
try {
|
try {
|
||||||
|
|
@ -172,7 +196,7 @@ const STAGES_PICKUP = [
|
||||||
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getActiveStageIndex = (order, smsLogs = []) => {
|
const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
|
||||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
||||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
||||||
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
||||||
|
|
@ -197,6 +221,12 @@ const getActiveStageIndex = (order, smsLogs = []) => {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Автодозвон: если робот согласовал слот — это уже «Согласовано»
|
||||||
|
const hasAutocallConfirmed = autocallLogs.some((l) => ["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome));
|
||||||
|
if (hasAutocallConfirmed) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
// SMS stage counts as done when any first_sms record exists (even failed), not just when delivered
|
// SMS stage counts as done when any first_sms record exists (even failed), not just when delivered
|
||||||
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
|
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
|
||||||
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
|
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
|
||||||
|
|
@ -206,10 +236,10 @@ const getActiveStageIndex = (order, smsLogs = []) => {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProgressStepper = ({ order, smsLogs = [] }) => {
|
const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
|
||||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
||||||
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
||||||
const activeIdx = getActiveStageIndex(order, smsLogs);
|
const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs);
|
||||||
|
|
||||||
if (activeIdx === -1) {
|
if (activeIdx === -1) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -276,7 +306,7 @@ const ProgressStepper = ({ order, smsLogs = [] }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Current Status Summary (compact card above timeline) ───────────
|
// ─── Current Status Summary (compact card above timeline) ───────────
|
||||||
const StatusSummary = ({ order, smsLogs = [] }) => {
|
const StatusSummary = ({ order, smsLogs = [], autocallLogs = [] }) => {
|
||||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
||||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
||||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
||||||
|
|
@ -350,6 +380,26 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
||||||
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
|
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Автодозвон — итог: показываем последний УСПЕШНЫЙ звонок (confirmed),
|
||||||
|
// если его не было — последний звонок любым исходом.
|
||||||
|
const confirmedAutocall = autocallLogs?.find((l) =>
|
||||||
|
["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome)
|
||||||
|
);
|
||||||
|
const lastAutocall = confirmedAutocall || (autocallLogs && autocallLogs.length > 0 ? autocallLogs[0] : null);
|
||||||
|
if (lastAutocall) {
|
||||||
|
const acMeta = AUTOCALL_OUTCOME_LABELS[lastAutocall.outcome] || { label: lastAutocall.outcome || "—", tone: "muted" };
|
||||||
|
const acSlot = autocallSlotText(lastAutocall);
|
||||||
|
const acValue = acSlot ? `${acMeta.label} · ${acSlot}` : acMeta.label;
|
||||||
|
items.push({
|
||||||
|
icon: "🤖",
|
||||||
|
label: "Автодозвон",
|
||||||
|
value: `${acValue}${lastAutocall.was_test_mode ? " · 🧪 тест" : ""}`,
|
||||||
|
tone: acMeta.tone,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({ icon: "🤖", label: "Автодозвон", value: "не звонили", tone: "muted" });
|
||||||
|
}
|
||||||
|
|
||||||
// Driver (delivery only)
|
// Driver (delivery only)
|
||||||
if (!isPickup) {
|
if (!isPickup) {
|
||||||
if (driverName) {
|
if (driverName) {
|
||||||
|
|
@ -394,7 +444,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
||||||
<span className="text-base flex-shrink-0">{item.icon}</span>
|
<span className="text-base flex-shrink-0">{item.icon}</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[11px] font-medium text-[var(--color-text-muted)] leading-tight">{item.label}</p>
|
<p className="text-[11px] font-medium text-[var(--color-text-muted)] leading-tight">{item.label}</p>
|
||||||
<p className={`text-sm font-semibold truncate ${textStyles[item.tone] || textStyles.muted}`}>
|
<p className={`text-sm font-semibold break-words ${textStyles[item.tone] || textStyles.muted}`}>
|
||||||
{item.value}
|
{item.value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -408,6 +458,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
||||||
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
const [history, setHistory] = useState(null);
|
const [history, setHistory] = useState(null);
|
||||||
const [smsLogs, setSmsLogs] = useState([]);
|
const [smsLogs, setSmsLogs] = useState([]);
|
||||||
|
const [autocallLogs, setAutocallLogs] = useState([]);
|
||||||
const [pushLogs, setPushLogs] = useState([]);
|
const [pushLogs, setPushLogs] = useState([]);
|
||||||
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
||||||
const [cityProfile, setCityProfile] = useState(null);
|
const [cityProfile, setCityProfile] = useState(null);
|
||||||
|
|
@ -440,6 +491,18 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
if (!smsError) setSmsLogs(smsData || []);
|
if (!smsError) setSmsLogs(smsData || []);
|
||||||
} catch { if (!cancelled) setSmsLogs([]); }
|
} catch { if (!cancelled) setSmsLogs([]); }
|
||||||
|
|
||||||
|
// Автодозвон — лог робо-звонков
|
||||||
|
try {
|
||||||
|
const { data: acData, error: acError } = await supabase
|
||||||
|
.from("autocall_log")
|
||||||
|
.select("id, outcome, attempt_no, scenario, dtmf_day, dtmf_half, changed, was_test_mode, sip_code, duration_sec, created_at")
|
||||||
|
.eq("order_group_id", order.id)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(50);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!acError) setAutocallLogs(acData || []);
|
||||||
|
} catch { if (!cancelled) setAutocallLogs([]); }
|
||||||
|
|
||||||
// Push is stored on the client phone, so it is available for every current and future order.
|
// Push is stored on the client phone, so it is available for every current and future order.
|
||||||
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
|
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
|
||||||
if (phoneNormalized) {
|
if (phoneNormalized) {
|
||||||
|
|
@ -561,6 +624,26 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (autocallLogs && autocallLogs.length > 0) {
|
||||||
|
for (const ac of autocallLogs) {
|
||||||
|
const meta = AUTOCALL_OUTCOME_LABELS[ac.outcome] || { label: `Автодозвон: ${ac.outcome || "—"}`, tone: "muted", icon: "🤖" };
|
||||||
|
const slot = autocallSlotText(ac);
|
||||||
|
events.push({
|
||||||
|
id: `autocall-${ac.id}`,
|
||||||
|
created_at: ac.created_at,
|
||||||
|
type: "autocall",
|
||||||
|
label: meta.label,
|
||||||
|
tone: meta.tone,
|
||||||
|
slot,
|
||||||
|
attemptNo: ac.attempt_no,
|
||||||
|
wasTest: ac.was_test_mode,
|
||||||
|
changed: ac.changed,
|
||||||
|
sipCode: ac.sip_code,
|
||||||
|
durationSec: ac.duration_sec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pushLogs && pushLogs.length > 0) {
|
if (pushLogs && pushLogs.length > 0) {
|
||||||
for (const push of pushLogs) {
|
for (const push of pushLogs) {
|
||||||
events.push({
|
events.push({
|
||||||
|
|
@ -609,7 +692,7 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
|
|
||||||
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
return events;
|
return events;
|
||||||
}, [history, smsLogs, pushLogs, invitation, cityProfile]);
|
}, [history, smsLogs, autocallLogs, pushLogs, invitation, cityProfile]);
|
||||||
|
|
||||||
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
|
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
|
||||||
const latestPush = pushLogs[0] || null;
|
const latestPush = pushLogs[0] || null;
|
||||||
|
|
@ -618,8 +701,8 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProgressStepper order={order} smsLogs={smsLogs} />
|
<ProgressStepper order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
|
||||||
<StatusSummary order={order} smsLogs={smsLogs} />
|
<StatusSummary order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
|
||||||
<div className={`rounded-xl border px-3 py-2.5 ${
|
<div className={`rounded-xl border px-3 py-2.5 ${
|
||||||
activePushSubscriptions.length > 0
|
activePushSubscriptions.length > 0
|
||||||
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
|
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
|
||||||
|
|
@ -693,6 +776,34 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ev.type === "autocall" && (
|
||||||
|
<>
|
||||||
|
<p className="mt-0.5 text-sm">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||||
|
ev.tone === "ok"
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||||
|
: ev.tone === "danger"
|
||||||
|
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
|
||||||
|
: ev.tone === "warning"
|
||||||
|
? "bg-[rgba(245,158,11,0.12)] text-[var(--color-warning)]"
|
||||||
|
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
||||||
|
}`}>
|
||||||
|
{ev.tone === "ok" ? "успешно" : ev.tone === "danger" ? "неуспешно" : ev.tone === "warning" ? "требует внимания" : "—"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{ev.slot && <p className="mt-0.5 text-xs text-[var(--color-text)]">Слот: {ev.slot}</p>}
|
||||||
|
{ev.changed && (
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--color-warning)]">⚠ Клиент правил выбор в звонке</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Попытка {ev.attemptNo || "—"}
|
||||||
|
{ev.durationSec ? ` · длительность ${ev.durationSec}с` : ""}
|
||||||
|
{ev.sipCode ? ` · SIP ${ev.sipCode}` : ""}
|
||||||
|
{ev.wasTest ? " · 🧪 тест" : ""}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{ev.type === "reason" && (
|
{ev.type === "reason" && (
|
||||||
<p className={`mt-0.5 text-xs ${
|
<p className={`mt-0.5 text-xs ${
|
||||||
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
|
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
|
import { getSmsFailureReason, isSmsFundsError } from "../../services/orderGroupViews";
|
||||||
|
|
||||||
// ── Status labels ────────────────────────────────────────────────────────────
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
const NOTIF_LABELS = {
|
const NOTIF_LABELS = {
|
||||||
|
|
@ -209,6 +210,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
const hasSecondSms = isDelivered(secondSmsLog);
|
const hasSecondSms = isDelivered(secondSmsLog);
|
||||||
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
|
const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status);
|
||||||
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
|
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
|
||||||
|
// Причина неудачи словами: в логе лежит только код сервиса (201, 202, ...).
|
||||||
|
const failureReason = (log) => {
|
||||||
|
if (!log) return null;
|
||||||
|
if (log.status === "expired") return "срок доставки сообщения истёк";
|
||||||
|
return getSmsFailureReason(log) || log.status;
|
||||||
|
};
|
||||||
|
const fundsProblem = [firstSmsLog, secondSmsLog].some((log) => isSmsFundsError(log));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
@ -226,11 +234,13 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{hasFirstSms ? (
|
{hasFirstSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом</div>
|
||||||
) : firstSmsFailed ? (
|
) : firstSmsFailed ? (
|
||||||
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}</div>
|
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} ⚠ не отправлено: {failureReason(firstSmsLog)}</div>
|
||||||
) : firstSmsAt ? (
|
) : firstSmsAt ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…</div>
|
||||||
) : notifStatus === "link_ready" ? (
|
) : notifStatus === "link_ready" ? (
|
||||||
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
||||||
|
) : notifStatus === "manual_required" && smsLog.length === 0 ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">SMS не отправлялись — согласование шло без SMS</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[var(--color-text-muted)]">не отправлено</div>
|
<div className="text-[var(--color-text-muted)]">не отправлено</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -245,7 +255,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{hasSecondSms ? (
|
{hasSecondSms ? (
|
||||||
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом</div>
|
||||||
) : secondSmsFailed ? (
|
) : secondSmsFailed ? (
|
||||||
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}</div>
|
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} ⚠ не отправлено: {failureReason(secondSmsLog)}</div>
|
||||||
) : notifStatus === "first_sms_sent" && countdown ? (
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
<div className="text-[var(--color-text-muted)]">
|
<div className="text-[var(--color-text-muted)]">
|
||||||
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
|
@ -267,7 +277,24 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{lastError && (
|
{lastError && (
|
||||||
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
⚠ {lastError}
|
⚠ {typeof lastError === "string" ? getSmsFailureReason(lastError) || lastError : lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ручное управление без ошибок в логе: объясняем, что это значит */}
|
||||||
|
{notifStatus === "manual_required" && !lastError && !fundsProblem && (
|
||||||
|
<div className="rounded-lg bg-[rgba(245,158,11,0.10)] px-2 py-1.5 text-[var(--color-text)]">
|
||||||
|
⚠ Клиент не согласовал доставку — SMS-уведомления по расписанию закончились. Никакой ошибки
|
||||||
|
отправки не было: сообщение клиенту не уходило, потому что оно и не требовалось.
|
||||||
|
{smsLog.length === 0 && " Записей об отправке SMS нет."} Дальше — вручную: позвоните клиенту
|
||||||
|
или отправьте SMS вручную.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Нехватка средств — самая частая причина, объясняем прямо в карточке */}
|
||||||
|
{fundsProblem && (
|
||||||
|
<div className="rounded-lg border border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
|
⛔ На счёте SMS-сервиса закончились средства — сообщение не ушло. Пополните счёт: отправка возобновится автоматически.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export const useOrderGroups = ({
|
||||||
}
|
}
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
|
|
||||||
if (!enabled) { setOrderGroups([]); setIsLoading(false); return; }
|
if (!enabled) { setIsLoading(false); return; }
|
||||||
|
|
||||||
// Single-group mode: load only the requested group (avoids pulling all
|
// Single-group mode: load only the requested group (avoids pulling all
|
||||||
// 430 groups with their full source_orders JSON — 2.6MB — for a detail view).
|
// 430 groups with their full source_orders JSON — 2.6MB — for a detail view).
|
||||||
|
|
@ -80,12 +80,17 @@ export const useOrderGroups = ({
|
||||||
|
|
||||||
if (groupsResult.error) {
|
if (groupsResult.error) {
|
||||||
setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки");
|
setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки");
|
||||||
setOrderGroups([]);
|
// Список не стираем — показываем прежние данные, а не пустой экран.
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = groupsResult.data || {};
|
const payload = groupsResult.data || {};
|
||||||
|
// Неожиданная форма ответа — тоже не стираем список.
|
||||||
|
if (!Array.isArray(payload.data)) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setOrderGroups(payload.data || []);
|
setOrderGroups(payload.data || []);
|
||||||
setTotalGroups(payload.total ?? (payload.data?.length || 0));
|
setTotalGroups(payload.total ?? (payload.data?.length || 0));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -110,7 +115,7 @@ export const useOrderGroups = ({
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
};
|
};
|
||||||
if (enabled) doLoad(); else { setOrderGroups([]); setIsLoading(false); }
|
if (enabled) doLoad(); else { setIsLoading(false); }
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [loadLiveData, enabled]);
|
}, [loadLiveData, enabled]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { Skeleton } from "../components/UI/Loading";
|
||||||
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
||||||
import {
|
import {
|
||||||
confirmDeliveryChoice,
|
confirmDeliveryChoice,
|
||||||
|
fetchBusinessSchedule,
|
||||||
fetchDeliveryInvitation,
|
fetchDeliveryInvitation,
|
||||||
} from "../services/deliveryInvitationApi";
|
} from "../services/deliveryInvitationApi";
|
||||||
|
|
||||||
|
|
@ -127,30 +128,52 @@ const isSundayKey = (dateKey) => {
|
||||||
return d.getUTCDay() === 0;
|
return d.getUTCDay() === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextWorkdayKey = (dateKey) => {
|
// Рабочие дни доставки из «Графика работы» (1=пн … 7=вс).
|
||||||
|
// Дефолт — прежнее поведение страницы: все дни, кроме воскресенья.
|
||||||
|
const DEFAULT_DELIVERY_DAYS = [1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
const isAllowedDeliveryDayKey = (dateKey, workDays = DEFAULT_DELIVERY_DAYS) => {
|
||||||
|
if (!dateKey) return false;
|
||||||
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
|
if (Number.isNaN(d.getTime())) return false;
|
||||||
|
const weekday = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
|
||||||
|
return workDays.includes(weekday);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextWorkdayKey = (dateKey, workDays = DEFAULT_DELIVERY_DAYS) => {
|
||||||
let next = addDaysToDateKey(dateKey, 1);
|
let next = addDaysToDateKey(dateKey, 1);
|
||||||
while (isSundayKey(next)) {
|
let guard = 0;
|
||||||
|
while (!isAllowedDeliveryDayKey(next, workDays) && guard < 14) {
|
||||||
next = addDaysToDateKey(next, 1);
|
next = addDaysToDateKey(next, 1);
|
||||||
|
guard += 1;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
const getAllowedDeliveryDateKeys = (referenceDate = new Date(), workDays = DEFAULT_DELIVERY_DAYS) => {
|
||||||
const todayKey = getBusinessTodayKey(referenceDate);
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
const firstWorkday = getNextWorkdayKey(todayKey, workDays);
|
||||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
const secondWorkday = getNextWorkdayKey(firstWorkday, workDays);
|
||||||
return new Set([firstWorkday, secondWorkday].filter(Boolean));
|
return new Set([firstWorkday, secondWorkday].filter(Boolean));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => {
|
const isAllowedDeliverySlotDate = (
|
||||||
|
dateKey,
|
||||||
|
referenceDate = new Date(),
|
||||||
|
workDays = DEFAULT_DELIVERY_DAYS,
|
||||||
|
) => {
|
||||||
if (!dateKey) {
|
if (!dateKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getAllowedDeliveryDateKeys(referenceDate).has(dateKey);
|
return getAllowedDeliveryDateKeys(referenceDate, workDays).has(dateKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const groupSlotsFromInvitation = (invitation, referenceDate = new Date()) => {
|
export const groupSlotsFromInvitation = (
|
||||||
|
invitation,
|
||||||
|
referenceDate = new Date(),
|
||||||
|
workDays = DEFAULT_DELIVERY_DAYS,
|
||||||
|
) => {
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -160,10 +183,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
||||||
const deliveryTime = invitation.deliveryTime;
|
const deliveryTime = invitation.deliveryTime;
|
||||||
|
|
||||||
if (!rawSlots.length && !deliveryDate) {
|
if (!rawSlots.length && !deliveryDate) {
|
||||||
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday)
|
// Fallback: generate default delivery slots (next 2 workdays, both halves)
|
||||||
const todayKey = getBusinessTodayKey(referenceDate);
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
const firstWorkday = getNextWorkdayKey(todayKey, workDays);
|
||||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
const secondWorkday = getNextWorkdayKey(firstWorkday, workDays);
|
||||||
return [
|
return [
|
||||||
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||||
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||||
|
|
@ -193,7 +216,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
||||||
|| deliveryDate
|
|| deliveryDate
|
||||||
|| "";
|
|| "";
|
||||||
|
|
||||||
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) {
|
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate, workDays)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +240,7 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) {
|
if (!isAllowedDeliverySlotDate(slotDate, referenceDate, workDays)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,6 +328,28 @@ export const ClientDeliveryPage = () => {
|
||||||
[token, invitation?.smsSentAt],
|
[token, invitation?.smsSentAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Рабочие дни и часы — из графика компании, те же, что использует робот.
|
||||||
|
const [pickupWorkDays, setPickupWorkDays] = React.useState([1, 2, 3, 4, 5]);
|
||||||
|
const [deliveryWorkDays, setDeliveryWorkDays] = React.useState(DEFAULT_DELIVERY_DAYS);
|
||||||
|
const [pickupSchedule, setPickupSchedule] = React.useState({
|
||||||
|
halfEnd: "13:00",
|
||||||
|
sameDayCutoff: "15:30",
|
||||||
|
});
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchBusinessSchedule()
|
||||||
|
.then((s) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPickupWorkDays(s.pickupDays);
|
||||||
|
setDeliveryWorkDays(s.deliveryDays);
|
||||||
|
setPickupSchedule({ halfEnd: s.pickupHalfEnd, sameDayCutoff: s.pickupSameDayCutoff });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
@ -350,7 +395,7 @@ export const ClientDeliveryPage = () => {
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const slots = groupSlotsFromInvitation(invitation, referenceDate);
|
const slots = groupSlotsFromInvitation(invitation, referenceDate, deliveryWorkDays);
|
||||||
|
|
||||||
const invitationState = invitation?.state || "awaiting_choice";
|
const invitationState = invitation?.state || "awaiting_choice";
|
||||||
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
||||||
|
|
@ -397,7 +442,7 @@ export const ClientDeliveryPage = () => {
|
||||||
setSelectedSlot(
|
setSelectedSlot(
|
||||||
buildSelectedSlotFromInvitation(
|
buildSelectedSlotFromInvitation(
|
||||||
loadedInvitation,
|
loadedInvitation,
|
||||||
groupSlotsFromInvitation(loadedInvitation, referenceDate),
|
groupSlotsFromInvitation(loadedInvitation, referenceDate, deliveryWorkDays),
|
||||||
) || effectiveSelectedSlot,
|
) || effectiveSelectedSlot,
|
||||||
);
|
);
|
||||||
setChoiceSaved(true);
|
setChoiceSaved(true);
|
||||||
|
|
@ -642,6 +687,8 @@ export const ClientDeliveryPage = () => {
|
||||||
onSelectSlot={handleSlotSelect}
|
onSelectSlot={handleSlotSelect}
|
||||||
selectedSlotId={selectedSlotId}
|
selectedSlotId={selectedSlotId}
|
||||||
referenceDate={referenceDate}
|
referenceDate={referenceDate}
|
||||||
|
pickupDays={pickupWorkDays}
|
||||||
|
schedule={pickupSchedule}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ describe("ClientDeliveryPage helpers", () => {
|
||||||
searchDate: "2026-04-14",
|
searchDate: "2026-04-14",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
deliveryType: "delivery",
|
||||||
deliveryDate: "2026-04-16",
|
deliveryDate: "2026-04-16",
|
||||||
deliveryTime: "После обеда",
|
deliveryTime: "После обеда",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
localDeliveryInvitationCache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рабочие дни из графика компании («График работы» в админке).
|
||||||
|
* Админка — единственный источник правды: и страница клиента, и робот-автодозвон
|
||||||
|
* обязаны предлагать одни и те же дни, иначе клиент выберет день, когда склад
|
||||||
|
* или доставка не работают. Номера дней: 1=пн … 7=вс.
|
||||||
|
*/
|
||||||
|
const SCHEDULE_FALLBACK = {
|
||||||
|
deliveryDays: [1, 2, 3, 4, 5, 6],
|
||||||
|
pickupDays: [1, 2, 3, 4, 5],
|
||||||
|
smsDays: [1, 2, 3, 4, 5],
|
||||||
|
pickupOpen: "09:00",
|
||||||
|
pickupClose: "17:30",
|
||||||
|
pickupHalfEnd: "13:00",
|
||||||
|
pickupSameDayCutoff: "15:30",
|
||||||
|
deliveryOpen: "09:00",
|
||||||
|
deliveryClose: "21:00",
|
||||||
|
deliveryHalfEnd: "15:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDays = (days, fallback) => {
|
||||||
|
const parsed = (Array.isArray(days) ? days : [])
|
||||||
|
.map((d) => Number(d))
|
||||||
|
.filter((d) => Number.isInteger(d) && d >= 1 && d <= 7);
|
||||||
|
return parsed.length ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchBusinessSchedule = async () => {
|
||||||
|
if (!hasSupabaseConfig || !supabase?.rpc) {
|
||||||
|
return { ...SCHEDULE_FALLBACK };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc("get_business_schedule");
|
||||||
|
if (error) throw error;
|
||||||
|
return {
|
||||||
|
deliveryDays: normalizeDays(data?.deliveryDays, SCHEDULE_FALLBACK.deliveryDays),
|
||||||
|
pickupDays: normalizeDays(data?.pickupDays, SCHEDULE_FALLBACK.pickupDays),
|
||||||
|
smsDays: normalizeDays(data?.smsDays, SCHEDULE_FALLBACK.smsDays),
|
||||||
|
pickupOpen: data?.pickupOpen || SCHEDULE_FALLBACK.pickupOpen,
|
||||||
|
pickupClose: data?.pickupClose || SCHEDULE_FALLBACK.pickupClose,
|
||||||
|
pickupHalfEnd: data?.pickupHalfEnd || SCHEDULE_FALLBACK.pickupHalfEnd,
|
||||||
|
pickupSameDayCutoff: data?.pickupSameDayCutoff || SCHEDULE_FALLBACK.pickupSameDayCutoff,
|
||||||
|
deliveryOpen: data?.deliveryOpen || SCHEDULE_FALLBACK.deliveryOpen,
|
||||||
|
deliveryClose: data?.deliveryClose || SCHEDULE_FALLBACK.deliveryClose,
|
||||||
|
deliveryHalfEnd: data?.deliveryHalfEnd || SCHEDULE_FALLBACK.deliveryHalfEnd,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Не удалось получить график работы, беру значения по умолчанию", e);
|
||||||
|
return { ...SCHEDULE_FALLBACK };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchPickupWorkDays = async () => (await fetchBusinessSchedule()).pickupDays;
|
||||||
|
|
||||||
|
export const fetchDeliveryWorkDays = async () => (await fetchBusinessSchedule()).deliveryDays;
|
||||||
|
|
||||||
export const fetchDeliveryInvitation = async (token) => {
|
export const fetchDeliveryInvitation = async (token) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error("Token is required");
|
throw new Error("Token is required");
|
||||||
|
|
|
||||||
|
|
@ -245,22 +245,176 @@ export const parseGroupDate = (value) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMS delivery problem tone used for both table highlighting and filtering.
|
* SMS status used for both table highlighting and filtering.
|
||||||
* Returns "ok" | "not_sent" | "not_delivered".
|
* Returns "not_sent" | "sent" | "delivered" | "error".
|
||||||
*/
|
*/
|
||||||
export const getSmsProblemTone = (group) => {
|
export const getSmsStatus = (group) => {
|
||||||
if (!group) return "ok";
|
if (!group) return "not_sent";
|
||||||
const firstSms = group.firstSmsSentAt || group.first_sms_sent_at;
|
const firstSms = group.firstSmsSentAt || group.first_sms_sent_at;
|
||||||
const secondSms = group.secondSmsSentAt || group.second_sms_sent_at;
|
const secondSms = group.secondSmsSentAt || group.second_sms_sent_at;
|
||||||
const lastError = group.lastSmsError;
|
const lastError = group.lastSmsError;
|
||||||
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → problem with delivery.
|
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → error.
|
||||||
if ((firstSms || secondSms) && lastError) return "not_delivered";
|
if ((firstSms || secondSms) && lastError) return "error";
|
||||||
// SMS should be expected but not sent at all yet.
|
// SMS should be expected but not sent at all yet.
|
||||||
if (!firstSms && !secondSms) return "not_sent";
|
if (!firstSms && !secondSms) return "not_sent";
|
||||||
// Sent but no error recorded — assume delivered / in progress.
|
// Sent but no error recorded — assume delivered / in progress.
|
||||||
|
return "delivered";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Для обратной совместимости
|
||||||
|
export const getSmsProblemTone = (group) => {
|
||||||
|
const s = getSmsStatus(group);
|
||||||
|
if (s === "error") return "not_delivered";
|
||||||
|
if (s === "not_sent") return "not_sent";
|
||||||
return "ok";
|
return "ok";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Статус SMS: человеческие значения для фильтра ─────────────────
|
||||||
|
export const SMS_STATUS_META = {
|
||||||
|
not_sent: { label: "Не отправлено", tone: "neutral" },
|
||||||
|
sent: { label: "Отправлено", tone: "info" },
|
||||||
|
delivered: { label: "Доставлено", tone: "accent" },
|
||||||
|
error: { label: "Ошибка", tone: "danger" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Причина неудачной отправки SMS ────────────────────────────────
|
||||||
|
// Коды SMS-сервиса (первая строка ответа) → человеческая причина.
|
||||||
|
// В логе лежит только цифра, поэтому расшифровываем её здесь.
|
||||||
|
export const SMS_ERROR_REASONS = {
|
||||||
|
"100": "сообщение в очереди SMS-сервиса",
|
||||||
|
"101": "сообщение передано оператору",
|
||||||
|
"102": "сообщение в пути",
|
||||||
|
"104": "истёк срок доставки сообщения",
|
||||||
|
"105": "сообщение удалено оператором",
|
||||||
|
"106": "сбой на телефоне абонента",
|
||||||
|
"107": "оператор не сообщил причину",
|
||||||
|
"108": "оператор отклонил сообщение",
|
||||||
|
"130": "лимит сообщений на этот номер в сутки",
|
||||||
|
"131": "лимит одинаковых сообщений в минуту",
|
||||||
|
"132": "лимит одинаковых сообщений в сутки",
|
||||||
|
"200": "неверный ключ доступа к SMS-сервису",
|
||||||
|
"201": "на счёте SMS-сервиса закончились средства",
|
||||||
|
"202": "неверный номер получателя",
|
||||||
|
"203": "пустой текст сообщения",
|
||||||
|
"230": "исчерпан общий суточный лимит аккаунта",
|
||||||
|
"231": "лимит одинаковых сообщений в минуту",
|
||||||
|
"232": "лимит одинаковых сообщений в сутки",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Код нехватки средств — по нему показываем отдельное предупреждение.
|
||||||
|
export const SMS_FUNDS_ERROR_CODE = "201";
|
||||||
|
|
||||||
|
const smsErrorCode = (log) => {
|
||||||
|
if (!log) return "";
|
||||||
|
const direct = String(log.sms_code ?? "").trim();
|
||||||
|
if (direct) return direct.match(/\d{3}/)?.[0] || direct;
|
||||||
|
const fromText = String(log.error_message ?? "").trim();
|
||||||
|
return fromText.match(/\d{3}/)?.[0] || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причина, по которой SMS не ушла — словами. Принимает строку лога
|
||||||
|
* (sms_campaign_log) либо значение lastSmsError у группы.
|
||||||
|
*/
|
||||||
|
export const getSmsFailureReason = (log) => {
|
||||||
|
if (!log) return null;
|
||||||
|
// В некоторых местах причина лежит просто строкой: "201" или "201\n"
|
||||||
|
if (typeof log === "string") {
|
||||||
|
const text = log.trim();
|
||||||
|
const onlyCode = text.match(/^(\d{3})$/)?.[1];
|
||||||
|
if (onlyCode && SMS_ERROR_REASONS[onlyCode]) return SMS_ERROR_REASONS[onlyCode];
|
||||||
|
// Технические строки из планировщика вида «Not delivered after 2 attempts
|
||||||
|
// (code=106)» переводим в человеческие: код объясняем словами.
|
||||||
|
const embedded = text.match(/code\s*=\s*(\d{3})/i)?.[1];
|
||||||
|
if (embedded && SMS_ERROR_REASONS[embedded]) {
|
||||||
|
return `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`;
|
||||||
|
}
|
||||||
|
// «Не доставлено после N попыток» без кода — говорим так, как есть.
|
||||||
|
const attempts = text.match(/after\s+(\d+)\s+attempts?/i);
|
||||||
|
if (/not delivered|delivery failed|failed after/i.test(text)) {
|
||||||
|
return attempts
|
||||||
|
? `клиент не получил сообщение за ${attempts[1]} попытки — телефон недоступен или оператор не доставил`
|
||||||
|
: "клиент не получил сообщение — телефон недоступен или оператор не доставил";
|
||||||
|
}
|
||||||
|
if (/limit exceeded/i.test(text)) {
|
||||||
|
return embedded && SMS_ERROR_REASONS[embedded]
|
||||||
|
? `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`
|
||||||
|
: "сработал лимит отправки — SMS-сервис временно блокирует рассылку";
|
||||||
|
}
|
||||||
|
return text.slice(0, 120) || null;
|
||||||
|
}
|
||||||
|
const code = smsErrorCode(log);
|
||||||
|
if (code && SMS_ERROR_REASONS[code]) return SMS_ERROR_REASONS[code];
|
||||||
|
const raw = String(log.error_message ?? "").trim();
|
||||||
|
if (code) return `код ${code}`;
|
||||||
|
if (!raw) return null;
|
||||||
|
return raw.slice(0, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Нехватка средств на счёте SMS-сервиса — самая частая и заметная причина. */
|
||||||
|
export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CODE;
|
||||||
|
|
||||||
|
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
|
||||||
|
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
|
||||||
|
// ВАЖНО: те же значения приходят из базы (функция autocall_status(order_groups)) —
|
||||||
|
// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся.
|
||||||
|
// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры.
|
||||||
|
export const AUTOCALL_STATUS_META = {
|
||||||
|
not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
|
||||||
|
confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
|
||||||
|
no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
|
||||||
|
no_input_day: { label: "Не выбрал день", short: "не выбрал день", tone: "warning", icon: "📅" },
|
||||||
|
no_input_half: { label: "Не выбрал время", short: "не выбрал время", tone: "warning", icon: "🕐" },
|
||||||
|
no_input_both: { label: "Не выбрал день и время", short: "не выбрал день и время", tone: "warning", icon: "🤐" },
|
||||||
|
manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" },
|
||||||
|
opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" },
|
||||||
|
excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Порядок для выпадающих списков — один список на всё приложение. */
|
||||||
|
export const AUTOCALL_STATUS_OPTIONS = [
|
||||||
|
{ value: "not_called", label: "Не звонили" },
|
||||||
|
{ value: "confirmed", label: "Согласовано" },
|
||||||
|
{ value: "no_answer", label: "Не дозвонились" },
|
||||||
|
{ value: "no_input_day", label: "Не выбрал день" },
|
||||||
|
{ value: "no_input_half", label: "Не выбрал время" },
|
||||||
|
{ value: "no_input_both", label: "Не выбрал день и время" },
|
||||||
|
{ value: "manual", label: "Нужен менеджер" },
|
||||||
|
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
|
||||||
|
{ value: "excluded", label: "Не звоним (исключение)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что именно клиент не выбрал: день, время или и то и другое.
|
||||||
|
*
|
||||||
|
* Считаем по всем звонкам заказа, а не только по последнему: если в одном
|
||||||
|
* звонке клиент не выбрал день, а в другом день выбрал, но не успел время —
|
||||||
|
* честнее сказать «не выбрал день и время», чем показывать половину картины.
|
||||||
|
*/
|
||||||
|
export const resolveNoInputKind = (calls = []) => {
|
||||||
|
const outcomes = new Set((calls || []).map((c) => c?.outcome));
|
||||||
|
const day = outcomes.has("no_input_day");
|
||||||
|
const half = outcomes.has("no_input_half");
|
||||||
|
if (day && half) return "no_input_both";
|
||||||
|
if (half) return "no_input_half";
|
||||||
|
if (day) return "no_input_day";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => {
|
||||||
|
if (optOut) return "opt_out";
|
||||||
|
if (exclusionReason) return "excluded";
|
||||||
|
if (!Array.isArray(calls) || calls.length === 0) return "not_called";
|
||||||
|
const last = calls[0];
|
||||||
|
if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed";
|
||||||
|
if (last.outcome === "no_answer") return "no_answer";
|
||||||
|
if (["no_input_day", "no_input_half"].includes(last.outcome)) {
|
||||||
|
return resolveNoInputKind(calls) || "no_input_day";
|
||||||
|
}
|
||||||
|
if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual";
|
||||||
|
return "not_called";
|
||||||
|
};
|
||||||
|
|
||||||
export const filterOrderGroups = (groups, filters = {}) => {
|
export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
const query = normalizeDate(filters.query).trim().toLowerCase();
|
const query = normalizeDate(filters.query).trim().toLowerCase();
|
||||||
const status = filters.status || "all";
|
const status = filters.status || "all";
|
||||||
|
|
@ -270,6 +424,7 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
const dateTo = normalizeDate(filters.dateTo);
|
const dateTo = normalizeDate(filters.dateTo);
|
||||||
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
||||||
const smsStatus = filters.smsStatus || "all";
|
const smsStatus = filters.smsStatus || "all";
|
||||||
|
const autocallStatus = filters.autocallStatus || "all";
|
||||||
|
|
||||||
const isWithinDateRange = (group) => {
|
const isWithinDateRange = (group) => {
|
||||||
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
||||||
|
|
@ -362,9 +517,20 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (smsStatus !== "all") {
|
if (smsStatus !== "all") {
|
||||||
const tone = getSmsProblemTone(group);
|
const status = getSmsStatus(group);
|
||||||
if (smsStatus === "problem_not_sent" && tone !== "not_sent") return false;
|
if (smsStatus === "not_sent" && status !== "not_sent") return false;
|
||||||
if (smsStatus === "problem_not_delivered" && tone !== "not_delivered") return false;
|
if (smsStatus === "sent" && status !== "sent") return false;
|
||||||
|
if (smsStatus === "delivered" && status !== "delivered") return false;
|
||||||
|
if (smsStatus === "error" && status !== "error") return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр «Статус автодозвона»
|
||||||
|
if (autocallStatus !== "all") {
|
||||||
|
const acStatus = group.autocall_status || group.autocallStatus || resolveAutocallStatus([], {
|
||||||
|
optOut: group.autocall_opt_out,
|
||||||
|
exclusionReason: group.autocall_exclusion_reason,
|
||||||
|
});
|
||||||
|
if (acStatus !== autocallStatus) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
|
|
@ -397,7 +563,7 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||||||
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
||||||
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
||||||
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
||||||
{ value: "status:manual_required", label: "Требует ручной обработки" },
|
{ value: "status:manual_required", label: "Нужен менеджер" },
|
||||||
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||||||
{ value: "status:ready_to_launch", label: "Готово к запуску" },
|
{ value: "status:ready_to_launch", label: "Готово к запуску" },
|
||||||
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },
|
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
deliveryDate,
|
||||||
deliveryTime,
|
deliveryTime,
|
||||||
deliveryDateSource: row.delivery_date_source || null,
|
deliveryDateSource: row.delivery_date_source || null,
|
||||||
|
autocallOptOut: !!row.autocall_opt_out,
|
||||||
|
autocall_opt_out: !!row.autocall_opt_out,
|
||||||
|
autocallStatus: row.autocall_status || null,
|
||||||
|
autocall_status: row.autocall_status || null,
|
||||||
|
autocallExclusionReason: row.autocall_exclusion_reason || null,
|
||||||
|
autocall_exclusion_reason: row.autocall_exclusion_reason || null,
|
||||||
deliveryType: effectiveDeliveryType,
|
deliveryType: effectiveDeliveryType,
|
||||||
pickupDate: row.pickup_date || null,
|
pickupDate: row.pickup_date || null,
|
||||||
pickupTimeSlot: row.pickup_time_slot || null,
|
pickupTimeSlot: row.pickup_time_slot || null,
|
||||||
|
|
@ -294,7 +300,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
|
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
|
||||||
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
|
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment, autocall_exclusion_reason, autocall_status`;
|
||||||
|
|
||||||
// Delivery statuses treated as "finished" — hidden by default in the board.
|
// Delivery statuses treated as "finished" — hidden by default in the board.
|
||||||
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
||||||
|
|
@ -610,7 +616,7 @@ export const fetchOrderGroups = async ({
|
||||||
|
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const cached = orderGroupsCache.get(cacheKey);
|
const cached = orderGroupsCache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return { data: cached, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ const OUTCOMES = new Set([
|
||||||
"no_input_day",
|
"no_input_day",
|
||||||
"no_input_half",
|
"no_input_half",
|
||||||
"operator",
|
"operator",
|
||||||
"opt_out",
|
|
||||||
"invalid_number",
|
"invalid_number",
|
||||||
"error",
|
"error",
|
||||||
]);
|
]);
|
||||||
|
|
@ -29,6 +28,8 @@ type AutocallBody = {
|
||||||
customer_phone?: string;
|
customer_phone?: string;
|
||||||
attempt_no?: number;
|
attempt_no?: number;
|
||||||
scenario?: string;
|
scenario?: string;
|
||||||
|
call_type?: string;
|
||||||
|
chosen_date?: string;
|
||||||
call_started_at?: string;
|
call_started_at?: string;
|
||||||
answered_at?: string | null;
|
answered_at?: string | null;
|
||||||
duration_sec?: number | null;
|
duration_sec?: number | null;
|
||||||
|
|
@ -50,6 +51,16 @@ function addDays(date: Date, days: number): string {
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** День по-человечески: сегодня / завтра / послезавтра, иначе дата дд.мм.гггг. */
|
||||||
|
function relativeDayLabel(dateKey: string): string {
|
||||||
|
const today = new Date();
|
||||||
|
for (const [offset, label] of [[0, "сегодня"], [1, "завтра"], [2, "послезавтра"]] as const) {
|
||||||
|
if (addDays(today, offset) === dateKey) return label;
|
||||||
|
}
|
||||||
|
const [y, m, d] = dateKey.split("-");
|
||||||
|
return y && m && d ? `${d}.${m}.${y}` : dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
const cors = getCorsHeaders(request, "private");
|
const cors = getCorsHeaders(request, "private");
|
||||||
|
|
@ -81,6 +92,7 @@ Deno.serve(async (request) => {
|
||||||
const campaignType = body.campaign_type || "autocall_slot";
|
const campaignType = body.campaign_type || "autocall_slot";
|
||||||
const attemptNo = body.attempt_no ?? 1;
|
const attemptNo = body.attempt_no ?? 1;
|
||||||
const scenario = body.scenario || "A";
|
const scenario = body.scenario || "A";
|
||||||
|
const callType = body.call_type === "pkp" ? "pkp" : "dlv";
|
||||||
const wasTest = body.was_test_mode ?? false;
|
const wasTest = body.was_test_mode ?? false;
|
||||||
|
|
||||||
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
|
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
|
||||||
|
|
@ -109,6 +121,7 @@ Deno.serve(async (request) => {
|
||||||
customer_phone: body.customer_phone,
|
customer_phone: body.customer_phone,
|
||||||
attempt_no: attemptNo,
|
attempt_no: attemptNo,
|
||||||
scenario,
|
scenario,
|
||||||
|
call_type: callType,
|
||||||
call_started_at: body.call_started_at || new Date().toISOString(),
|
call_started_at: body.call_started_at || new Date().toISOString(),
|
||||||
answered_at: body.answered_at || null,
|
answered_at: body.answered_at || null,
|
||||||
duration_sec: body.duration_sec ?? null,
|
duration_sec: body.duration_sec ?? null,
|
||||||
|
|
@ -148,32 +161,41 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
// ── confirmed*: записываем слот ──
|
// ── confirmed*: записываем слот ──
|
||||||
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
|
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
|
||||||
const dayOffset = body.dtmf_day === 2 ? 2 : 1;
|
// День называет сам диалог (chosen_date) — робот перечисляет конкретные
|
||||||
const deliveryDate = addDays(new Date(), dayOffset);
|
// дни окна самовывоза, поэтому «1/2/3» само по себе дату не задаёт.
|
||||||
|
// Если дата не пришла (старые call-файлы) — считаем по цифре дня.
|
||||||
|
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
|
||||||
|
? (body.chosen_date as string)
|
||||||
|
: null;
|
||||||
|
const dayOffset = body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0;
|
||||||
|
const deliveryDate = chosenDate || addDays(new Date(), body.dtmf_day ? dayOffset : 1);
|
||||||
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
|
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
|
||||||
const dayLabel = body.dtmf_day ? DAY_LABEL[body.dtmf_day] : "завтра";
|
const dayLabel = chosenDate
|
||||||
|
? relativeDayLabel(chosenDate)
|
||||||
|
: body.dtmf_day
|
||||||
|
? DAY_LABEL[body.dtmf_day]
|
||||||
|
: "завтра";
|
||||||
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
|
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
delivery_date_source: "autocall",
|
delivery_date_source: "autocall",
|
||||||
delivery_status: "agreed",
|
delivery_status: "agreed",
|
||||||
notification_status: "confirmed",
|
notification_status: "confirmed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
called: true,
|
called: true,
|
||||||
called_at: new Date().toISOString(),
|
called_at: new Date().toISOString(),
|
||||||
called_comment: comment,
|
called_comment: comment,
|
||||||
next_notification_check_at: null,
|
next_notification_check_at: null,
|
||||||
};
|
};
|
||||||
|
if (timeSlot) update.delivery_time = timeSlot;
|
||||||
if (isPickup) {
|
if (isPickup) {
|
||||||
update.pickup_date = deliveryDate;
|
update.pickup_date = deliveryDate;
|
||||||
if (timeSlot) update.pickup_time_slot = timeSlot;
|
if (timeSlot) update.pickup_time_slot = timeSlot;
|
||||||
} else {
|
|
||||||
update.delivery_date = deliveryDate;
|
|
||||||
if (timeSlot) update.delivery_time = timeSlot;
|
|
||||||
}
|
}
|
||||||
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
|
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
|
||||||
}
|
}
|
||||||
// ── Эскалация ──
|
// ── Эскалация ──
|
||||||
else if (["operator", "opt_out", "invalid_number"].includes(body.outcome)) {
|
else if (["operator", "invalid_number"].includes(body.outcome)) {
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
notification_status: "manual_required",
|
notification_status: "manual_required",
|
||||||
called: true,
|
called: true,
|
||||||
|
|
@ -181,14 +203,14 @@ Deno.serve(async (request) => {
|
||||||
called_comment:
|
called_comment:
|
||||||
body.outcome === "operator"
|
body.outcome === "operator"
|
||||||
? "Просил менеджера"
|
? "Просил менеджера"
|
||||||
: body.outcome === "opt_out"
|
: "Неверный номер",
|
||||||
? "Отказался от автозвонков"
|
|
||||||
: "Неверный номер",
|
|
||||||
};
|
};
|
||||||
if (body.outcome === "opt_out") update.autocall_opt_out = true;
|
|
||||||
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
|
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
|
||||||
}
|
}
|
||||||
// ── no_answer / no_input: решаем, эскалировать ли ──
|
// ── no_answer / no_input: решаем, эскалировать ли ──
|
||||||
|
// Пороги берём из настроек кампании (админка → Автодозвон): раньше здесь
|
||||||
|
// стояли константы 2 и 5, поэтому поля «Не нажал ×N → менеджер» и
|
||||||
|
// «Всего попыток» в интерфейсе ни на что не влияли.
|
||||||
else {
|
else {
|
||||||
const { count: noInputCount } = await supabase
|
const { count: noInputCount } = await supabase
|
||||||
.from("autocall_log")
|
.from("autocall_log")
|
||||||
|
|
@ -200,8 +222,14 @@ Deno.serve(async (request) => {
|
||||||
.select("id", { count: "exact", head: true })
|
.select("id", { count: "exact", head: true })
|
||||||
.eq("order_group_id", orderGroupId);
|
.eq("order_group_id", orderGroupId);
|
||||||
|
|
||||||
const noInputEscalate = 2; // из настроек, но пока константа
|
const { data: settings } = await supabase
|
||||||
const maxAttempts = 5; // из настроек, но пока константа
|
.from("sms_campaign_settings")
|
||||||
|
.select("no_input_escalate_after, max_attempts")
|
||||||
|
.eq("campaign_type", "autocall_slot")
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
const noInputEscalate = Number(settings?.no_input_escalate_after ?? 2);
|
||||||
|
const maxAttempts = Number(settings?.max_attempts ?? 5);
|
||||||
const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts;
|
const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts;
|
||||||
if (escalate) {
|
if (escalate) {
|
||||||
await supabase.from("order_groups").update({
|
await supabase.from("order_groups").update({
|
||||||
|
|
|
||||||
|
|
@ -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