diff --git a/docs/autocall/autocall-how-it-works.html b/docs/autocall/autocall-how-it-works.html index 70e2f39..350002d 100644 --- a/docs/autocall/autocall-how-it-works.html +++ b/docs/autocall/autocall-how-it-works.html @@ -83,6 +83,6 @@ -
СУПЕР САМ · ДОСТАВКА И САМОВЫВОЗ

Автодозвон: как это работает

Робот звонит клиенту, согласует день и половину дня — без участия оператора

Что описано: правила звонка, паузы, поведение в нештатных случаях (не дозвонились,

сбросили трубку, не выбрали день/время), где что настраивается и что видит менеджер.

 

Основано на текущем коде и настройках боевой системы, без обещаний и продажи.

Что делает робот

Коротко: он не продаёт и не консультирует — только согласует день и половину дня

Три задачи

  • Дозвониться, когда заказ готов и клиент не подтвердил день по ссылке из SMS
  • Предложить конкретные дни голосом и получить выбор цифрой
  • Записать выбор в заказ — дату, половину дня и признак «согласовано роботом»

Чего он не делает

  • Не соединяет с живым менеджером — такой цифры в диалоге нет
  • Не спорит и не уговаривает: нет ответа — повторит по расписанию
  • Не предлагает нерабочие дни и не звонит вне рабочего времени

Как это выглядит для клиента

Путь заказа до звонка

Когда именно звучит звонок

1. Заказ из 1Сприходит готовымпо обмену
›
2. Ссылка и SMSссылка создаётсясама, SMS уходит
›
3. Пауза 90 минутпосле SMS илипосле попытки, еслиSMS не ушло
›
4. Звонок роботаесли клиент неподтвердил по ссылке
›
5. Результат в заказдата и половина дняв самом заказе

Важно

Откуда берутся дни и время

Админка → Расписание → Рабочие дни: один источник правды

Что настраивается

  • Рабочие дни склада (самовывоз) и рабочие дни доставки
  • Часы: открытие и закрытие склада, часы доставки
  • «Первая половина — до» — граница между половинами дня
  • «Сегодня предлагаем до» — до какого времени предлагать сегодняшний день

Как это работает в коде

  • Окно звонка считается рабочими днями: выходные не сокращают окно, а пропускаются
  • Робот называет реальные дни голосом: «сегодня», «в понедельник», «во вторник»
  • Клиентская ссылка использует те же дни — расхождений между звонком и ссылкой нет

Текущие значения в системе

Самовывоз: какие дни предлагает

Окно — «день готовности + два рабочих дня», выходные пропускаются

Заказ готовЧто предложит роботПочему так
в понедельникпн, вт, сртри рабочих дня подряд
в пятницупт, пн, втсуббота и воскресенье — выходные склада
в пятницу после 15:30пн, вт«сегодня» больше не предлагаем (настройка)
в понедельник после 13:00пн, вт, срполовину дня не спрашиваем — сразу вторая
заказ готов давнозвонка нетокно самовывоза прошло — звонить не о чем

Две настройки, которые это двигают

Доставка: не в день готовности

Нужен запас времени, чтобы собрать маршрут — поэтому начинаем со следующего дня

Заказ готовЧто предложит робот
в понедельниквторник и среда
в пятницупонедельник и вторник (в субботу не возим)
в четверг, звонок в пятницупятница и понедельник
в пятницу, звонок в понедельникпонедельник и вторник

Ключевое отличие от самовывоза

Как проходит разговор

Робот задаёт вопрос и ждёт цифру; молчание не считается согласием

ШагЧто говорит роботЧто делает клиентСколько ждёт
Приветствие«Ваш заказ готов. Выберите день и время доставки»слушает—
День«Нажмите один, чтобы доставить. В понедельник. Нажмите два…»нажимает 1 или 29 секунд
Половина дня«Первая половина дня или вторая?»нажимает 1 или 29 секунд
Подтверждение«Первая половина дня» — озвучивает выборслушает—
Изменить?подсказка про кнопку 2 — один разнажимает 2, чтобы переделать6 секунд

Что делает при ошибке ввода

Что можно нажать

Всего несколько цифр — и одна из них «больше не звоните»

ЦифраЗначениеЧто происходит
1первый предложенный деньробот переходит к выбору половины дня
2второй предложенный деньробот переходит к выбору половины дня
3третий день (только самовывоз)робот переходит к выбору половины дня
1 / 2половина дняпервая половина / вторая (после 13:00 — только вторая)
2 после подтвержденияизменить выборробот спрашивает день и время заново — один раз
любая другая цифране принимаетсявопрос повторяется один раз; после двух таких звонков — менеджеру

Что происходит после подтверждения

Если не дозвонились

Робот различает «не взял трубку» и «вместо человека ответил автоответчик/оператор»

Как понимает, что трубку не взяли

  • Никто не ответил за 45 секунд — попытка считается неудачной
  • Трубку подняли, но гудки шли больше 20 секунд и ни одной цифры не нажали — это объявление оператора, а не человек: пишем «не взял трубку»
  • Живой человек почти всегда берёт трубку раньше, поэтому правило надёжно отделяет автоответчик

Что происходит дальше

  • Повторный звонок — не раньше чем через час (настройка)
  • Всего не больше 5 попыток на заказ (настройка)
  • Звонит только в рабочие часы кампании: 09:00–20:00, дни — по настройке
  • Исчерпали попытки — заказ уходит менеджеру, автозвонок по нему останавливается

Для менеджера это видно в карточке

Если сбросили трубку в середине разговора

Робот запоминает то, что успел услышать, и не портит уже согласованный слот

Когда положили трубкуЧто запишет роботЧто увидит менеджер
до выбора дняответил, день не выбрал«взял, не выбрал день» — попытка зачтена
день выбрал, время не успелдень выбран, время нет«взял, не выбрал время»
выбор подтвердил и сбросилслот согласовандата и половина дня в заказе, статус «согласовано»
начал менять выбор и сбросилостаётся прежний слот«правка не доведена» — прежнее согласование в силе

Почему так

Когда подключается менеджер

Робот доводит дело до конца сам, но в трёх случаях передаёт человеку

Не выбрал день/время 2 раза

  • Робот дважды фиксировал «взял, но не выбрал»
  • Заказ помечается «нужен менеджер», комментарий «не дозвонились / не выбрал»
  • Порог «не выбрал × N» настраивается в админке автодозвона

Исчерпаны 5 попыток

  • Каждый звонок — отдельная попытка
  • Когда попытки закончились, автозвонок прекращается
  • Заказ остаётся в списке у менеджера

Неверный номер

  • Если номер не существует или недоступен — задача менеджеру
  • Менеджер может поставить «Не звонить» — тогда робот этого клиента не беспокоит
  • Такая отметка видна в карточке заказа и учитывается при обзвоне

Что видит менеджер в карточке заказа

Когда робот не звонит вообще

Список условий, при которых звонка не будет — проверяются до набора номера

По клиенту и заказу

  • Клиент сам подтвердил день по ссылке — звонок не нужен
  • Менеджер поставил «Не звонить» — робот этого клиента не беспокоит
  • Номер в списке исключений — например, на МТС-диапазон звонки не идут
  • Самовывоз: автообзвон по самовывозу включается отдельной галочкой (сейчас выключен)

По времени и географии

  • Вне рабочих часов кампании (09:00–20:00) и вне рабочих дней
  • Город исключён целиком или для города выключен канал
  • В админке автодозвона выключены звонки по городу — например, Ялта: SMS идут, звонков нет
  • Меньше 90 минут прошло после последней попытки отправки SMS: если приглашение ушло — считаем от отправки, если не ушло — от попытки

Админка автодозвона: где это переключается

Что записывается в заказ

Результат звонка виден там же, где всё остальное

ПолеЗначение
Датаконкретная дата, которую назвал робот (например, понедельник 21.09)
Время«первая половина дня» или «вторая половина дня»
Источник«согласовано роботом» — видно, что выбор сделал клиент в звонке
Статус«согласовано» / «нужен менеджер» / «не звонить» (запрет ставит менеджер)
Комментарий«Согласовано роботом: завтра, первая половина дня»

Зачем это нужно

Тестовый режим и текущее состояние

Сейчас обзвон клиентов выключен — система в тестовом режиме

ПараметрЗначение сейчасЧто будет при запуске
Обзвон клиентоввыключенвключается отдельной командой
Тестовый номерзвонит только на негомассовый обзвон идёт клиентам
Кнопка «Позвонить сейчас»звонит клиенту сразутак же — звонок клиенту
Звонки по самовывозувыключены галочкойвключаются отдельно от доставки

Как проверяется перед запуском

Что пока не учитывается

Честный список: это важно знать до запуска

График

  • Праздники: календаря нерабочих дат нет — если праздник попадёт в окно, робот его предложит
  • Часы доставки используются частично: границу половины дня робот проговаривает словами, без конкретного времени

Настройки, которые пока не влияют

  • «Попыток в день» — поле есть, но расписание повторов задаётся интервалом между попытками
  • «Попытки следующего дня (10:00, 14:00)» — пока не используются
  • «Таймаут звонка, 30 секунд» — звонок ждёт ответа 45 секунд (значение в самом звонке)

Что предлагаем уточнить до боевого запуска

Коротко

Одна страница вместо всей презентации

Что делает система

  • Ждёт 90 минут после SMS (или после последней попытки отправки, если SMS не ушло) и звонит, если клиент не подтвердил день сам
  • Предлагает только рабочие дни по графику: самовывоз — включая сегодня, доставка — со следующего дня
  • Спрашивает день и половину дня, подтверждает вслух, даёт один раз переделать выбор
  • Граница половины дня — 13:00: звоним после 13:00, значит говорим про вторую половину
  • Повторяет попытки не чаще чем раз в час, максимум 5 раз, только 09:00–20:00 в рабочие дни
  • После двух «взял, но не выбрал» или исчерпания попыток передаёт заказ менеджеру
  • Каждая попытка и результат видны в карточке заказа; ручной звонок идёт клиенту и показывает своё состояние

Что осталось решить

+
СУПЕР САМ · ДОСТАВКА И САМОВЫВОЗ

Автодозвон: как это работает

Робот звонит клиенту, согласует день и половину дня — без участия оператора

Что описано: правила звонка, паузы, поведение в нештатных случаях (не дозвонились,

сбросили трубку, не выбрали день/время), где что настраивается и что видит менеджер.

 

Основано на текущем коде и настройках боевой системы, без обещаний и продажи.

Что делает робот

Коротко: он не продаёт и не консультирует — только согласует день и половину дня

Три задачи

  • Дозвониться, когда заказ готов и клиент не подтвердил день по ссылке из SMS
  • Предложить конкретные дни голосом и получить выбор цифрой
  • Записать выбор в заказ — дату, половину дня и признак «согласовано роботом»

Чего он не делает

  • Не соединяет с живым менеджером — такой цифры в диалоге нет
  • Не спорит и не уговаривает: нет ответа — повторит по расписанию
  • Не предлагает нерабочие дни и не звонит вне рабочего времени

Как это выглядит для клиента

Путь заказа до звонка

Когда именно звучит звонок

1. Заказ из 1Сприходит готовымпо обмену
›
2. Ссылка и SMSссылка создаётсясама, SMS уходит
›
3. Пауза 90 минутпосле SMS илипосле попытки, еслиSMS не ушло
›
4. Звонок роботаесли клиент неподтвердил по ссылке
›
5. Результат в заказдата и половина дняв самом заказе

Важно

Откуда берутся дни и время

Админка → Расписание → Рабочие дни: один источник правды

Что настраивается

  • Рабочие дни склада (самовывоз) и рабочие дни доставки
  • Часы: открытие и закрытие склада, часы доставки
  • «Первая половина — до» — граница между половинами дня
  • «Сегодня предлагаем до» — до какого времени предлагать сегодняшний день

Как это работает в коде

  • Окно звонка считается рабочими днями: выходные не сокращают окно, а пропускаются
  • Робот называет реальные дни голосом: «сегодня», «в понедельник», «во вторник»
  • Клиентская ссылка использует те же дни — расхождений между звонком и ссылкой нет

Текущие значения в системе

Самовывоз: какие дни предлагает

Окно — «день готовности + два рабочих дня», выходные пропускаются

Заказ готовЧто предложит роботПочему так
в понедельникпн, вт, сртри рабочих дня подряд
в пятницупт, пн, втсуббота и воскресенье — выходные склада
в пятницу после 15:30пн, вт«сегодня» больше не предлагаем (настройка)
в понедельник после 13:00пн, вт, срполовину дня не спрашиваем — сразу вторая
заказ готов давнозвонка нетокно самовывоза прошло — звонить не о чем

Две настройки, которые это двигают

Доставка: не в день готовности

Нужен запас времени, чтобы собрать маршрут — поэтому начинаем со следующего дня

Заказ готовЧто предложит робот
в понедельниквторник и среда
в пятницупонедельник и вторник (в субботу не возим)
в четверг, звонок в пятницупятница и понедельник
в пятницу, звонок в понедельникпонедельник и вторник

Ключевое отличие от самовывоза

Как проходит разговор

Робот задаёт вопрос и ждёт цифру; молчание не считается согласием

ШагЧто говорит роботЧто делает клиентСколько ждёт
Приветствие«Ваш заказ готов. Выберите день и время доставки»слушает—
День«Нажмите один, чтобы доставить. В понедельник. Нажмите два…»нажимает 1 или 29 секунд
Половина дня«Первая половина дня или вторая?»нажимает 1 или 29 секунд
Подтверждение«Первая половина дня» — озвучивает выборслушает—
Изменить?подсказка про кнопку 2 — один разнажимает 2, чтобы переделать6 секунд

Что делает при ошибке ввода

Что можно нажать

Всего несколько цифр — и одна из них «больше не звоните»

ЦифраЗначениеЧто происходит
1первый предложенный деньробот переходит к выбору половины дня
2второй предложенный деньробот переходит к выбору половины дня
3третий день (только самовывоз)робот переходит к выбору половины дня
1 / 2половина дняпервая половина / вторая (после 13:00 — только вторая)
2 после подтвержденияизменить выборробот спрашивает день и время заново — один раз
любая другая цифране принимаетсявопрос повторяется один раз; после двух таких звонков — менеджеру

Что происходит после подтверждения

Если не дозвонились

Робот различает «не взял трубку» и «вместо человека ответил автоответчик/оператор»

Как понимает, что трубку не взяли

  • Никто не ответил за 45 секунд — попытка считается неудачной
  • Трубку подняли, но гудки шли больше 20 секунд и ни одной цифры не нажали — это объявление оператора, а не человек: пишем «не взял трубку»
  • Живой человек почти всегда берёт трубку раньше, поэтому правило надёжно отделяет автоответчик

Что происходит дальше

  • Повторный звонок — не раньше чем через час (настройка)
  • Всего не больше 5 попыток на заказ (настройка)
  • Звонит только в рабочие часы кампании: 09:00–20:00, дни — по настройке
  • Исчерпали попытки — заказ уходит менеджеру, автозвонок по нему останавливается

Для менеджера это видно в карточке

Если сбросили трубку в середине разговора

Робот запоминает то, что успел услышать, и не портит уже согласованный слот

Когда положили трубкуЧто запишет роботЧто увидит менеджер
до выбора дняответил, день не выбрал«взял, не выбрал день» — попытка зачтена
день выбрал, время не успелдень выбран, время нет«взял, не выбрал время»
выбор подтвердил и сбросилслот согласовандата и половина дня в заказе, статус «согласовано»
начал менять выбор и сбросилостаётся прежний слот«правка не доведена» — прежнее согласование в силе

Почему так

Клиент не выбрал дату после приглашения

Срок ожидания считаем от последнего контакта — SMS или звонка робота

Что настроено в системеОт чего считаем срокКогда заказ уходит менеджеру
Только SMS-приглашениеот последнего SMSчерез 3 ч (настройка в админке)
Плюс автодозвонждём звонок робота, отсчёт — от негочерез 3 ч после последнего звонка
Плюс второе приглашениесначала ждём второе SMS, потом звонокпосле последнего контакта по последнему каналу
Звонить нельзя (гео, исключение, «не звонить»)от последнего SMSчерез 3 ч — звонок не ждём

Что важно

Когда подключается менеджер

Робот доводит дело до конца сам, но в трёх случаях передаёт человеку

Не выбрал день/время 2 раза

  • Робот дважды фиксировал «взял, но не выбрал»
  • Заказ помечается «нужен менеджер», комментарий «не дозвонились / не выбрал»
  • Порог «не выбрал × N» настраивается в админке автодозвона

Исчерпаны 5 попыток

  • Каждый звонок — отдельная попытка
  • Когда попытки закончились, автозвонок прекращается
  • Заказ остаётся в списке у менеджера

Неверный номер

  • Если номер не существует или недоступен — задача менеджеру
  • Менеджер может поставить «Не звонить» — тогда робот этого клиента не беспокоит
  • Такая отметка видна в карточке заказа и учитывается при обзвоне

Что видит менеджер в карточке заказа

Когда робот не звонит вообще

Список условий, при которых звонка не будет — проверяются до набора номера

По клиенту и заказу

  • Клиент сам подтвердил день по ссылке — звонок не нужен
  • Менеджер поставил «Не звонить» — робот этого клиента не беспокоит
  • Номер в списке исключений — например, на МТС-диапазон звонки не идут
  • Самовывоз: автообзвон по самовывозу включается отдельной галочкой (сейчас выключен)

По времени и географии

  • Вне рабочих часов кампании (09:00–20:00) и вне рабочих дней
  • Город исключён целиком или для города выключен канал
  • В админке автодозвона выключены звонки по городу — например, Ялта: SMS идут, звонков нет
  • Меньше 90 минут прошло после последней попытки отправки SMS: если приглашение ушло — считаем от отправки, если не ушло — от попытки

Админка автодозвона: где это переключается

Что записывается в заказ

Результат звонка виден там же, где всё остальное

ПолеЗначение
Датаконкретная дата, которую назвал робот (например, понедельник 21.09)
Время«первая половина дня» или «вторая половина дня»
Источник«согласовано роботом» — видно, что выбор сделал клиент в звонке
Статус«согласовано» / «нужен менеджер» / «не звонить» (запрет ставит менеджер)
Комментарий«Согласовано роботом: завтра, первая половина дня»

Зачем это нужно

Тестовый режим и текущее состояние

Сейчас обзвон клиентов выключен — система в тестовом режиме

ПараметрЗначение сейчасЧто будет при запуске
Обзвон клиентоввыключенвключается отдельной командой
Тестовый номерзвонит только на негомассовый обзвон идёт клиентам
Кнопка «Позвонить сейчас»звонит клиенту сразутак же — звонок клиенту
Звонки по самовывозувыключены галочкойвключаются отдельно от доставки

Как проверяется перед запуском

Что пока не учитывается

Честный список: это важно знать до запуска

График

  • Праздники: календаря нерабочих дат нет — если праздник попадёт в окно, робот его предложит
  • Часы доставки используются частично: границу половины дня робот проговаривает словами, без конкретного времени

Настройки, которые пока не влияют

  • «Попыток в день» — поле есть, но расписание повторов задаётся интервалом между попытками
  • «Попытки следующего дня (10:00, 14:00)» — пока не используются
  • «Таймаут звонка, 30 секунд» — звонок ждёт ответа 45 секунд (значение в самом звонке)

Что предлагаем уточнить до боевого запуска

Коротко

Одна страница вместо всей презентации

Что делает система

  • Ждёт 90 минут после SMS (или после последней попытки отправки, если SMS не ушло) и звонит, если клиент не подтвердил день сам
  • Предлагает только рабочие дни по графику: самовывоз — включая сегодня, доставка — со следующего дня
  • Спрашивает день и половину дня, подтверждает вслух, даёт один раз переделать выбор
  • Граница половины дня — 13:00: звоним после 13:00, значит говорим про вторую половину
  • Повторяет попытки не чаще чем раз в час, максимум 5 раз, только 09:00–20:00 в рабочие дни
  • После двух «взял, но не выбрал» или исчерпания попыток передаёт заказ менеджеру
  • Каждая попытка и результат видны в карточке заказа; ручной звонок идёт клиенту и показывает своё состояние

Что осталось решить

diff --git a/docs/autocall/autocall-how-it-works.pdf b/docs/autocall/autocall-how-it-works.pdf index 5fbe784..b826e2e 100644 Binary files a/docs/autocall/autocall-how-it-works.pdf and b/docs/autocall/autocall-how-it-works.pdf differ diff --git a/docs/autocall/deck_content.py b/docs/autocall/deck_content.py index 71b73ba..fb0b2f8 100644 --- a/docs/autocall/deck_content.py +++ b/docs/autocall/deck_content.py @@ -211,6 +211,26 @@ SLIDES = [ "Если клиент уже согласовал слот, а потом начал менять и бросил трубку — прежний выбор сохраняется, ничего не теряется", ]}, }, + { + "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": "Когда подключается менеджер", diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 083e797..2353d78 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -537,7 +537,8 @@ def get_queued_calls(conn): if ids: cur.execute(""" SELECT og.id, og.customer_phone, og.customer_phone_normalized, - og.created_at, og.delivery_type, og.town + og.created_at, og.delivery_type, og.town, + og.notification_status, og.delivery_date, og.autocall_opt_out FROM order_groups og WHERE og.id IN %s """, (ids,)) og = {r["id"]: r for r in cur.fetchall()} @@ -549,6 +550,9 @@ def get_queued_calls(conn): 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 @@ -586,6 +590,18 @@ def process_queue(conn, settings): 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 + # Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) — + # роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено. + # Заявку НЕ закрываем (правило владельца) — просто не обрабатываем. + agreed = (str(q.get("notification_status") or "") in ("confirmed", "manual_required", "opt_out") + or q.get("delivery_date") is not None) + if agreed and not is_test: + log.info( + f"Queue item {q['queue_id']} (group {group_id}): заказ уже согласован " + f"(статус {q.get('notification_status')}, дата {q.get('delivery_date')}) — " + "звонок не нужен, заявка остаётся в очереди" + ) + continue if group_id not in attempt_map: if group_id: with conn.cursor() as cur: diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py index 2ae8fdd..909b254 100644 --- a/scripts/sms_manual_campaign.py +++ b/scripts/sms_manual_campaign.py @@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine) Заменяет n8n workflow "Ручное управление" Логика: - Если после второй SMS (notification_status = 'second_sms_sent') - прошло auto_manual_after_hours и клиент не согласовал + Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч) + после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и (delivery_status = 'pending_confirmation'): → notification_status = 'manual_required' → delivery_status = 'manual_confirmation_required' - → next_notification_check_at = +3 мин → Telegram-уведомление + Ожидание считаем только по тем каналам, что реально включены в системе + (load_channels): + • второе приглашение включено → ждём, пока оно уйдёт; + • автодозвон включён и номеру звонить можно → ждём звонок, отсчёт + начнётся от него (позвонили позже — ожидание начинается заново); + • канал выключен / звонить нельзя (не звонить, гео, исключение) — + не ждём его и переводим по сроку ожидания. + Расписание: 9-21, Пн-Сб (настраивается из админки) Скрипт НЕ отправляет SMS — только перевод в ручное управление. """ @@ -111,61 +118,129 @@ def is_within_work_hours(settings): # ─── State Machine ─────────────────────────────────────────────────────────── -def get_groups_to_manual(conn): - """Группы, где срок ожидания истёк и нужно перевести в ручное управление. +def get_groups_to_manual(conn, channels=None, manual_after_hours=3): + """Группы, где клиент не выбрал дату/время и пора в ручную работу. - Сценарии: - 1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS - 2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS - 3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа - 4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа + Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого + приглашения: если клиенту позвонили позже, ожидание начинается заново. + + Каналы берём из настроек: ждём только те шаги, которые реально включены. + • второе приглашение включено → ждём, пока оно уйдёт; + • автодозвон включён и этому номеру звонить можно → ждём звонка; + • канал выключен (или звонить нельзя: не звонить/гео/исключение) — + не ждём его и переводим в ручную работу по сроку ожидания. + + Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие + заказы в системе ведёт только менеджер, автоматика их не трогает. """ + channels = channels or {} with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute(""" + cur.execute(r""" + WITH contact AS ( + SELECT og.id, + GREATEST( + COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'), + COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'), + COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'), + COALESCE(og.called_at, TIMESTAMP 'epoch'), + COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND COALESCE(l.status, '') IN + ('sent','checking','delivered','send_failed','error','limit_exceeded')), + TIMESTAMP 'epoch'), + COALESCE((SELECT max(a.created_at) FROM autocall_log a + WHERE a.order_group_id = og.id), TIMESTAMP 'epoch') + ) AS last_contact_at, + (SELECT count(*) FROM autocall_log a + WHERE a.order_group_id = og.id) AS call_attempts + FROM order_groups og + ), + callable AS ( + SELECT og.id, + (og.autocall_opt_out = false + AND COALESCE(og.customer_phone, '') <> '' + AND NOT EXISTS ( + SELECT 1 FROM autocall_city_rules cr + WHERE cr.city = og.town AND cr.calls_enabled = false) + AND NOT EXISTS ( + SELECT 1 FROM sms_city_rules cr2 + WHERE cr2.city = og.town AND cr2.enabled = false + AND cr2.campaign_type IN ('*', 'autocall_slot') + AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE) + AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE)) + AND NOT EXISTS ( + SELECT 1 FROM autocall_exclusions e + WHERE e.active = true + AND ((e.rule_type = 'phone' + AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10)) + OR (e.rule_type = 'prefix' + AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10)) + LIKE e.value || '%%'))) + ) AS can_call + FROM order_groups og + ) SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.notification_status, og.delivery_link, og.first_sms_sent_at, og.second_sms_sent_at, og.sms_sent_at, og.next_notification_check_at, - og.sms_attempts + og.sms_attempts, + c.last_contact_at, c.call_attempts, cl.can_call FROM order_groups og + JOIN contact c ON c.id = og.id + JOIN callable cl ON cl.id = og.id WHERE og.delivery_status = 'pending_confirmation' - -- Гео-исключения — общие для SMS и звонков + -- Клиент уже выбрал дату/время — в ручную не переводим + AND og.delivery_date IS NULL + -- Контакта не было — отсчитывать не от чего + AND c.last_contact_at > TIMESTAMP 'epoch' + -- Срок ожидания истёк от последнего контакта + AND NOW() - c.last_contact_at >= make_interval(hours => %s) + -- Второе приглашение ждём, только если канал включён + AND (NOT %s OR og.second_sms_sent_at IS NOT NULL) + -- Звонок ждём, только если автодозвон включён и звонить можно + AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call) + -- Гео-исключения — общие для SMS и ручного управления AND NOT EXISTS ( SELECT 1 FROM sms_city_rules r WHERE r.city = og.town AND r.enabled = false AND r.campaign_type IN ('*', 'manual') - AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) + AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE) ) - AND ( - -- 1. second_sms_sent + срок истёк - (COALESCE(og.notification_status, '') = 'second_sms_sent' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())) - -- 2. first_sms_sent + срок истёк (>3h после отправки) - -- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL) - -- Иначе second_campaign не успеет отправить вторую SMS - OR (COALESCE(og.notification_status, '') = 'first_sms_sent' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) - AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours' - AND og.second_sms_sent_at IS NOT NULL) - -- 3. link_ready + next_check в прошлом + нет активных SMS в логе - OR (COALESCE(og.notification_status, '') = 'link_ready' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) - AND NOT EXISTS ( - SELECT 1 FROM sms_campaign_log scl - WHERE scl.order_group_id = og.id - AND scl.status IN ('sent', 'checking') - AND scl.created_at > NOW() - INTERVAL '2 hours' - )) - -- 4. sms_sending/checking + >3h без доставки (зависло) - OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking') - AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours') - ) - ORDER BY og.created_at ASC - """) + ORDER BY c.last_contact_at ASC + """, ( + int(manual_after_hours or 3), + bool(channels.get("second_sms")), + bool(channels.get("autocall")), + )) return [dict(r) for r in cur.fetchall()] + +def load_channels(conn): + """Какие каналы связи сейчас включены в системе. + + `first_sms` — первое приглашение, `second_sms` — повторное SMS, + `autocall` — робот-автодозвон (настройки кампании 'autocall_slot'). + От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги. + """ + channels = {"first_sms": True, "second_sms": False, "autocall": False} + try: + with conn.cursor() as cur: + cur.execute(""" + SELECT campaign_type, enabled + FROM sms_campaign_settings + WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot') + """) + for campaign_type, enabled in cur.fetchall(): + if campaign_type == "autocall_slot": + channels["autocall"] = bool(enabled) + else: + channels[campaign_type] = bool(enabled) + except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS + pass + return channels + def update_order_group(conn, group_id, fields): with conn.cursor() as cur: set_parts = [] @@ -182,35 +257,55 @@ def update_order_group(conn, group_id, fields): # ─── Основная логика ───────────────────────────────────────────────────────── -def step_move_to_manual(conn, settings): +def step_move_to_manual(conn, settings, channels=None): """Перевод групп в ручное управление""" tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) - delay_minutes = settings.get("auto_manual_after_hours", 3) + wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3) + channels = channels or load_channels(conn) - groups = get_groups_to_manual(conn) - log.info(f"Step 1: {len(groups)} groups to move to manual") + groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours) + log.info( + f"Step 1: {len(groups)} groups to move to manual " + f"(ожидание {wait_hours} ч от последнего контакта; каналы: " + f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, " + f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, " + f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})" + ) moved = 0 + dry_run = os.environ.get("DRY_RUN") == "1" for group in groups: group_id = str(group["id"]) name = group.get("customer_name") or group.get("group_key", "—") phone = group.get("customer_phone", "") delivery_link = group.get("delivery_link", "") + attempts = group.get("sms_attempts") or 1 - log.info(f"Group {group_id}: moving to manual_required ({name})") + if dry_run: + log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})") + moved += 1 + continue + + log.info( + f"Group {group_id}: moving to manual_required ({name}); " + f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}" + ) update_order_group(conn, group_id, { "notification_status": "manual_required", "delivery_status": "manual_confirmation_required", - "sms_attempts": 2, + # Попытки НЕ подменяем: показываем столько, сколько реально отправлено + "sms_attempts": attempts, "last_sms_error": None, - "next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'", + # Дальше ждать нечего — проверки по этому заказу закончились + "next_notification_check_at": None, "status": "manual_required", }) send_telegram( f"🔧 Ручное управление\n{name} ({phone})\n" - f"Клиент не согласовал доставку после двух SMS\n" + f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта " + f"({'звонки' if channels.get('autocall') else 'SMS'})\n" f"Ссылка: {delivery_link}", tg_chat, ) @@ -272,8 +367,9 @@ def main(): cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'") conn.commit() log.info("RESTART: manual campaign — will process all eligible groups now") - else: - moved = step_move_to_manual(conn, settings) + + channels = load_channels(conn) + moved = step_move_to_manual(conn, settings, channels=channels) log.info(f"Run summary: moved_to_manual={moved}") diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index adc03b4..51fbf61 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -511,7 +511,7 @@ export const SmsCampaignPanel = () => { )} )} - updateSetting("auto_manual_after_hours", parseInt(v) || 3)} /> + updateSetting("auto_manual_after_hours", parseInt(v) || 3)} hint="Отсчёт от последнего контакта: SMS или звонок робота. Если автодозвон включён — ждём звонок, если включено второе SMS — ждём его." /> updateSetting("telegram_chat_id", v)} /> @@ -804,7 +804,7 @@ export const SmsCampaignPanel = () => { }; // ── Helpers ────────────────────────────────────────────────────────────────── -const SettingField = ({ label, value, onChange }) => ( +const SettingField = ({ label, value, onChange, hint }) => ( ); diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index 2cc03d8..7d2016d 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -161,9 +161,23 @@ export const AutocallStatusCard = ({ order, userRole }) => { }, [order?.id, order?.delivery_type, order?.created_at]); const autocallBlocked = availability ? !availability.possible : false; + // Заказ уже согласован — дату выбрал клиент (в том числе в звонке) или поставил менеджер + const orderAgreed = Boolean(order?.delivery_date || order?.deliveryDate) + || ["confirmed", "manual_required", "opt_out"].includes( + String(order?.notification_status || order?.notificationStatus || ""), + ); + const process = useMemo( - () => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }), - [recentQueue, callAfter, settings, callUnreachable], + () => 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"; @@ -338,17 +352,28 @@ export const AutocallStatusCard = ({ order, userRole }) => { {/* Кнопки */} {canManage && (
- {autocallBlocked && ( + {autocallBlocked && !orderAgreed && (
🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами.
)} + {orderAgreed && ( +
+ Заказ уже согласован — автодозвон не нужен. Если надо переспросить клиента, позвоните сами. +
+ )}