diff --git a/docs/ats/ss-dialog.agi b/docs/ats/ss-dialog.agi index 239d692..897d3e2 100644 --- a/docs/ats/ss-dialog.agi +++ b/docs/ats/ss-dialog.agi @@ -30,6 +30,16 @@ digit () { # $1=озвучка, $2=таймаут мс -> DIGIT DIGIT=$(echo "$RESP" | sed -n 's/.*result=\([0-9]*\).*/\1/p') [ -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 + DIGIT="" + else + ANY_DIGIT=1 + fi +} # Чтение channel-переменной из call-файла (Setvar) через AGI. getvar () { # $1=имя -> VAR say "GET VARIABLE $1" @@ -172,17 +182,57 @@ if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then 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 - digit "ru/ss_pkp_day${DAYS}" 9000 + 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 - 9) play "ru/ss_optout"; OUTCOME=opt_out; break ;; - 0) play "ru/ss_operator"; OUTCOME=operator; break ;; + 9) play "ru/ss_optout"; OUTCOME=opt_out; break ;; 1|2|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;; *) CHOSEN_DAY="" ;; esac if [ -z "$CHOSEN_DAY" ]; then - digit "ru/ss_pkp_day${DAYS}" 9000 + 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="" ;; diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index 0602109..0d33a85 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -236,19 +236,41 @@ def get_exclusion_match(conn, phone): """, (phone, phone)) return cur.fetchone() -def pickup_window(sms_sent_at, now=None): +def load_work_days(conn, field="pickup_days"): + """Рабочие дни по графику компании (1=пн … 7=вс). + + График правит админ в «График работы» (business_schedule_settings). + Звонки обязаны предлагать только рабочие дни: в пятницу «послезавтра» — + это воскресенье, когда самовывоз не работает. + """ + 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") + row = cur.fetchone() or {} + raw = row.get("pickup_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}") + return default + + +def pickup_window(sms_sent_at, work_days=None, now=None): """Даты, на которые предлагаем самовывоз. Правило: забрать можно в день готовности (день попадания в базу — он же день отправки ссылки) и два следующих дня, то есть максимум три дня - включая сегодняшний. Окно отсчитывается от дня готовности, а список - начинается с сегодня: - • готов сегодня → сегодня, завтра, послезавтра (3) + включая сегодняшний. Выходные и нерабочие дни пропускаем, отсчёт ведём + от сегодня: + • готов сегодня → сегодня, завтра, послезавтра (3 рабочих дня) • готов вчера → сегодня, завтра (2) - • готов позавчера → только сегодня (1) + • готов позавчера → сегодня (1) • готов раньше → окно закрыто, звонить не о чем (0) + • в пятницу готов → пятница и суббота (воскресенье закрыто) """ 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() @@ -258,7 +280,8 @@ def pickup_window(sms_sent_at, now=None): dates = [] day = today while day <= last_allowed and len(dates) < 3: - dates.append(day) + if day.isoweekday() in work_days: + dates.append(day) day += timedelta(days=1) return [d.isoformat() for d in dates] @@ -308,7 +331,10 @@ def build_call_file(group, settings, attempt_no, is_test_call=False): # голосом и возвращает выбранную дату, а не абстрактный «день 1/2». pickup_days = [] if call_type == "pkp": - pickup_days = pickup_window(group.get("sms_sent_at") or group.get("created_at")) + pickup_days = pickup_window( + group.get("sms_sent_at") or group.get("created_at"), + settings.get("pickup_work_days"), + ) if not pickup_days: # Окно самовывоза закрыто (заказ готов более двух дней назад). return None @@ -549,6 +575,8 @@ def main(): conn = get_db_conn() try: settings = load_settings(conn) + # Рабочие дни для окна самовывоза (выходные робот не предлагает) + settings["pickup_work_days"] = load_work_days(conn) # Добор «не взял трубку»: заявки, по которым сеть так и не ответила # (истёкший call-файл, AGI не запускался). Только работа с БД. diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index d8c2d6c..6dce74b 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -15,6 +15,7 @@ import { formatDeliveryDate } from "../components/client/deliveryDateFormatting" import { confirmDeliveryChoice, fetchDeliveryInvitation, + fetchPickupWorkDays, } from "../services/deliveryInvitationApi"; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; @@ -305,6 +306,20 @@ export const ClientDeliveryPage = () => { [token, invitation?.smsSentAt], ); + // Рабочие дни самовывоза — из графика компании, те же, что называет робот. + const [pickupWorkDays, setPickupWorkDays] = React.useState([1, 2, 3, 4, 5]); + React.useEffect(() => { + let cancelled = false; + fetchPickupWorkDays() + .then((days) => { + if (!cancelled) setPickupWorkDays(days); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + React.useEffect(() => { let cancelled = false; @@ -642,6 +657,7 @@ export const ClientDeliveryPage = () => { onSelectSlot={handleSlotSelect} selectedSlotId={selectedSlotId} referenceDate={referenceDate} + pickupDays={pickupWorkDays} /> ) : null} diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index 7cddf3e..00dc0c0 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -183,6 +183,31 @@ export const __resetLocalDeliveryInvitationCache = () => { localDeliveryInvitationCache.clear(); }; +/** + * Рабочие дни самовывоза из графика компании («График работы»). + * Робот и страница клиента обязаны предлагать одни и те же дни, иначе + * клиент выберет день, который склад не работает (например, воскресенье). + * Номера дней: 1=пн … 7=вс. + */ +export const fetchPickupWorkDays = async () => { + const fallback = [1, 2, 3, 4, 5]; + if (!hasSupabaseConfig || !supabase?.rpc) { + return fallback; + } + try { + const { data, error } = await supabase.rpc("get_business_schedule"); + if (error) throw error; + const days = Array.isArray(data?.pickupDays) ? data.pickupDays : []; + const parsed = days + .map((d) => Number(d)) + .filter((d) => Number.isInteger(d) && d >= 1 && d <= 7); + return parsed.length ? parsed : fallback; + } catch (e) { + console.warn("Не удалось получить график самовывоза, беру пн-пт", e); + return fallback; + } +}; + export const fetchDeliveryInvitation = async (token) => { if (!token) { throw new Error("Token is required");