diff --git a/docs/ats/ss-dialog.agi b/docs/ats/ss-dialog.agi index 897d3e2..99a4356 100644 --- a/docs/ats/ss-dialog.agi +++ b/docs/ats/ss-dialog.agi @@ -27,16 +27,26 @@ OUT="${RES}-${UNIQ}.txt" say () { echo "$1"; read -r resp; echo "$(date +%T) $1 -> $resp" >> "$LOG"; RESP="$resp"; } digit () { # $1=озвучка, $2=таймаут мс -> DIGIT say "GET DATA $1 ${2:-9000} 1" - DIGIT=$(echo "$RESP" | sed -n 's/.*result=\([0-9]*\).*/\1/p') + # GET DATA отдаёт саму цифру (200 result=1). Тишина приходит как «(timeout)», + # а нажатый «0» — как result=0: раньше их путали и «0» (позвать менеджера) + # был неотличим от молчания. + case "$RESP" in + *"(timeout)"*|*"result=-1"*) DIGIT="" ;; + *) DIGIT=$(echo "$RESP" | sed -n 's/^200 result=\([0-9]*\)$/\1/p') ;; + esac [ -n "$DIGIT" ] && ANY_DIGIT=1 } wait_digit () { # $1=таймаут мс -> DIGIT (после EXEC Playback; 0 у Asterisk = тишина) say "WAIT FOR DIGIT ${1:-9000}" - DIGIT=$(echo "$RESP" | sed -n 's/.*result=\([0-9]*\).*/\1/p') - if [ "$DIGIT" = "0" ] || [ -z "$DIGIT" ]; then + # 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 } @@ -80,6 +90,10 @@ 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="" # Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку, @@ -223,6 +237,7 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then 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|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;; *) CHOSEN_DAY="" ;; esac @@ -235,6 +250,7 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then fi CHOSEN_DAY=$DIGIT case "$CHOSEN_DAY" in + 0) play "ru/ss_operator"; OUTCOME=operator; break ;; 1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;; *) CHOSEN_DAY="" ;; esac @@ -277,6 +293,83 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then 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 + 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 diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index ab1e600..b681f09 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -239,20 +239,23 @@ def get_exclusion_match(conn, phone): def load_work_days(conn, field="pickup_days"): """Рабочие дни по графику компании (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] try: 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 {} - 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()}) return days or default except Exception as e: - log.warning(f"Не удалось прочитать график работы, беру пн-пт: {e}") + log.warning(f"Не удалось прочитать график работы ({column}), беру пн-пт: {e}") return default @@ -291,6 +294,22 @@ def load_work_hours(conn): 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): """Даты, на которые предлагаем самовывоз. @@ -313,15 +332,7 @@ def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None): today = local_now.date() ready = (sms_sent_at or now).astimezone(tz).date() - # День готовности + два следующих рабочих дня. - 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 + window = work_day_window(ready, work_days, 3) 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] +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) первую половину дня ещё можно предлагать клиенту.""" @@ -386,6 +417,15 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): # Окно самовывоза закрыто (заказ готов более двух дней назад). return None + # Доставка: только рабочие дни графика и не в день готовности. Суббота — + # выходной, поэтому «готов в пятницу» даёт понедельник и вторник. + delivery_days = [] + if call_type == "dlv": + delivery_days = delivery_window( + group.get("sms_sent_at") or group.get("created_at"), + settings.get("delivery_work_days"), + ) + lines = [ f"Channel: PJSIP/{dial}@mmedia", 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_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}", f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}", + f"Setvar: DELIVERY_DAY1={delivery_days[0] if len(delivery_days) > 0 else ''}", + f"Setvar: DELIVERY_DAY2={delivery_days[1] if len(delivery_days) > 1 else ''}", f"Setvar: API_URL={API_URL}", f"Setvar: API_KEY={API_KEY}", f"Setvar: TEST_MODE={1 if test_mode else 0}", @@ -622,8 +664,10 @@ def main(): conn = get_db_conn() try: 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) # Добор «не взял трубку»: заявки, по которым сеть так и не ответила diff --git a/supabase/functions/autocall-result/index.ts b/supabase/functions/autocall-result/index.ts index 64829d4..26bffb4 100644 --- a/supabase/functions/autocall-result/index.ts +++ b/supabase/functions/autocall-result/index.ts @@ -212,6 +212,9 @@ Deno.serve(async (request) => { await supabase.from("order_groups").update(update).eq("id", orderGroupId); } // ── no_answer / no_input: решаем, эскалировать ли ── + // Пороги берём из настроек кампании (админка → Автодозвон): раньше здесь + // стояли константы 2 и 5, поэтому поля «Не нажал ×N → менеджер» и + // «Всего попыток» в интерфейсе ни на что не влияли. else { const { count: noInputCount } = await supabase .from("autocall_log") @@ -223,8 +226,14 @@ Deno.serve(async (request) => { .select("id", { count: "exact", head: true }) .eq("order_group_id", orderGroupId); - const noInputEscalate = 2; // из настроек, но пока константа - const maxAttempts = 5; // из настроек, но пока константа + const { data: settings } = await supabase + .from("sms_campaign_settings") + .select("no_input_escalate_after, max_attempts") + .eq("campaign_type", "autocall_slot") + .maybeSingle(); + + const noInputEscalate = Number(settings?.no_input_escalate_after ?? 2); + const maxAttempts = Number(settings?.max_attempts ?? 5); const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts; if (escalate) { await supabase.from("order_groups").update({