fix(autocall): доставка предлагает реальные рабочие дни графика (пт → пн/вт), эскалация по настройкам админки, WAIT FOR DIGIT и «0 — менеджер»

This commit is contained in:
Hermes 2026-09-15 09:40:43 +02:00
parent 3706f64ca7
commit 44f58ce9e7
3 changed files with 168 additions and 22 deletions

View File

@ -27,16 +27,26 @@ 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 [ -n "$DIGIT" ] && ANY_DIGIT=1
} }
wait_digit () { # $1=таймаут мс -> DIGIT (после EXEC Playback; 0 у Asterisk = тишина) wait_digit () { # $1=таймаут мс -> DIGIT (после EXEC Playback; 0 у Asterisk = тишина)
say "WAIT FOR DIGIT ${1:-9000}" say "WAIT FOR DIGIT ${1:-9000}"
DIGIT=$(echo "$RESP" | sed -n 's/.*result=\([0-9]*\).*/\1/p') # WAIT FOR DIGIT возвращает ASCII-код нажатой цифры (49 = «1»), 0 = тишина,
if [ "$DIGIT" = "0" ] || [ -z "$DIGIT" ]; then # -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="" DIGIT=""
else else
DIGIT=$(printf "\\$(printf '%03o' "$RAW")")
ANY_DIGIT=1 ANY_DIGIT=1
fi fi
} }
@ -80,6 +90,10 @@ getvar PICKUP_DAY1; PICKUP_DAY1=$VAR
getvar PICKUP_DAY2; PICKUP_DAY2=$VAR getvar PICKUP_DAY2; PICKUP_DAY2=$VAR
getvar PICKUP_DAY3; PICKUP_DAY3=$VAR getvar PICKUP_DAY3; PICKUP_DAY3=$VAR
getvar PICKUP_DAY1_FIRST; PICKUP_DAY1_FIRST=$VAR getvar PICKUP_DAY1_FIRST; PICKUP_DAY1_FIRST=$VAR
# Даты доставки (планировщик берёт рабочие дни графика доставки из админки:
# не в день готовности и без выходных — готов в пятницу → понедельник и вторник)
getvar DELIVERY_DAY1; DELIVERY_DAY1=$VAR
getvar DELIVERY_DAY2; DELIVERY_DAY2=$VAR
CHOSEN_DATE="" CHOSEN_DATE=""
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку, # Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
@ -223,6 +237,7 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
CHOSEN_DAY=$DIGIT CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in case "$CHOSEN_DAY" in
9) play "ru/ss_optout"; OUTCOME=opt_out; break ;; 9) play "ru/ss_optout"; OUTCOME=opt_out; break ;;
0) play "ru/ss_operator"; OUTCOME=operator; break ;;
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;; 1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;; *) CHOSEN_DAY="" ;;
esac esac
@ -235,6 +250,7 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
fi fi
CHOSEN_DAY=$DIGIT CHOSEN_DAY=$DIGIT
case "$CHOSEN_DAY" in case "$CHOSEN_DAY" in
0) play "ru/ss_operator"; OUTCOME=operator; break ;;
1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;; 1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
*) CHOSEN_DAY="" ;; *) CHOSEN_DAY="" ;;
esac esac
@ -277,6 +293,83 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
done done
fi 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
9) play "ru/ss_optout"; OUTCOME=opt_out; break ;;
0) play "ru/ss_operator"; OUTCOME=operator; break ;;
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
0) play "ru/ss_operator"; OUTCOME=operator; break ;;
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

View File

@ -239,20 +239,23 @@ def get_exclusion_match(conn, phone):
def load_work_days(conn, field="pickup_days"): def load_work_days(conn, field="pickup_days"):
"""Рабочие дни по графику компании (1=пн … 7=вс). """Рабочие дни по графику компании (1=пн … 7=вс).
График правит админ в «График работы» (business_schedule_settings). График правит админ в «График работы» (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] default = [1, 2, 3, 4, 5]
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT pickup_days FROM business_schedule_settings WHERE id = 1") cur.execute(f"SELECT {column} AS days FROM business_schedule_settings WHERE id = 1")
row = cur.fetchone() or {} row = cur.fetchone() or {}
raw = row.get("pickup_days") or default raw = row.get("days") or default
days = sorted({int(str(d).strip()) for d in raw if str(d).strip().isdigit()}) days = sorted({int(str(d).strip()) for d in raw if str(d).strip().isdigit()})
return days or default return days or default
except Exception as e: except Exception as e:
log.warning(f"Не удалось прочитать график работы, беру пн-пт: {e}") log.warning(f"Не удалось прочитать график работы ({column}), беру пн-пт: {e}")
return default return default
@ -291,6 +294,22 @@ def load_work_hours(conn):
return dict(defaults) 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): def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
"""Даты, на которые предлагаем самовывоз. """Даты, на которые предлагаем самовывоз.
@ -313,15 +332,7 @@ def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
today = local_now.date() today = local_now.date()
ready = (sms_sent_at or now).astimezone(tz).date() ready = (sms_sent_at or now).astimezone(tz).date()
# День готовности + два следующих рабочих дня. window = work_day_window(ready, work_days, 3)
window = []
day = ready
guard = 0
while len(window) < 3 and guard < 30:
if day.isoweekday() in work_days:
window.append(day)
day += timedelta(days=1)
guard += 1
first_day = today first_day = today
# Поздно для сегодняшнего забора — окно начинается со следующего дня. # Поздно для сегодняшнего забора — окно начинается со следующего дня.
@ -330,6 +341,26 @@ def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None):
return [d.isoformat() for d in window if d >= first_day] 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): def is_first_half_available(now=None, half_end_minutes=None):
"""До границы половины дня (настройка склада, по умолчанию 12:00) """До границы половины дня (настройка склада, по умолчанию 12:00)
первую половину дня ещё можно предлагать клиенту.""" первую половину дня ещё можно предлагать клиенту."""
@ -386,6 +417,15 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
# Окно самовывоза закрыто (заказ готов более двух дней назад). # Окно самовывоза закрыто (заказ готов более двух дней назад).
return None 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",
f"CallerID: {CALLER_ID}", f"CallerID: {CALLER_ID}",
@ -404,6 +444,8 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 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_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: 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}",
@ -622,8 +664,10 @@ 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) # дни робот не предлагает ни по самовывозу, ни по доставке.
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) settings["work_hours"] = load_work_hours(conn)
# Добор «не взял трубку»: заявки, по которым сеть так и не ответила # Добор «не взял трубку»: заявки, по которым сеть так и не ответила

View File

@ -212,6 +212,9 @@ Deno.serve(async (request) => {
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")
@ -223,8 +226,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({