fix(autocall): самовывоз только по рабочим дням графика + робот называет реальные дни; страница клиента читает график

This commit is contained in:
Hermes 2026-09-11 15:40:29 +02:00
parent 139c895290
commit 270ab39abc
4 changed files with 130 additions and 11 deletions

View File

@ -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
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 ;;
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="" ;;

View File

@ -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,6 +280,7 @@ def pickup_window(sms_sent_at, now=None):
dates = []
day = today
while day <= last_allowed and len(dates) < 3:
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 не запускался). Только работа с БД.

View File

@ -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}

View File

@ -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");