feat(autocall): окно самовывоза 3 дня (сегодня/завтра/послезавтра), даты из диалога, гео-исключения как у SMS
This commit is contained in:
parent
feb3fc1b07
commit
f62a6b525b
|
|
@ -65,6 +65,12 @@ getvar GROUP_ID; GROUP_ID=$VAR
|
||||||
getvar ATTEMPT; ATTEMPT=$VAR
|
getvar ATTEMPT; ATTEMPT=$VAR
|
||||||
getvar TEST_MODE; TEST_MODE=$VAR
|
getvar TEST_MODE; TEST_MODE=$VAR
|
||||||
getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR
|
getvar CUSTOMER_PHONE; CUSTOMER_PHONE=$VAR
|
||||||
|
# Даты самовывоза (заполняет планировщик по окну «готовность + 0..2 дня»)
|
||||||
|
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
|
||||||
|
CHOSEN_DATE=""
|
||||||
|
|
||||||
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
|
# Пишем состояние ПОСЛЕ КАЖДОГО шага: если клиент положит трубку,
|
||||||
# выбор всё равно сохранён (AGI умирает молча, финальной записи не будет).
|
# выбор всё равно сохранён (AGI умирает молча, финальной записи не будет).
|
||||||
|
|
@ -105,6 +111,7 @@ send_result () {
|
||||||
\"attempt_no\":${ATTEMPT:-1},
|
\"attempt_no\":${ATTEMPT:-1},
|
||||||
\"scenario\":\"$SCENARIO\",
|
\"scenario\":\"$SCENARIO\",
|
||||||
\"call_type\":\"${TYPE:-dlv}\",
|
\"call_type\":\"${TYPE:-dlv}\",
|
||||||
|
\"chosen_date\":\"${CHOSEN_DATE:-}\",
|
||||||
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
\"call_started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||||
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
\"answered_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||||
\"duration_sec\":0,
|
\"duration_sec\":0,
|
||||||
|
|
@ -156,6 +163,70 @@ if [ "$SCENARIO" = "C" ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─── Самовывоз: даты приходят из call-файла (PICKUP_DAY1..3) ─────────
|
||||||
|
# Робот называет конкретные дни, окно считает планировщик:
|
||||||
|
# готов сегодня → сегодня/завтра/послезавтра, вчера → сегодня/завтра, позавчера → только сегодня.
|
||||||
|
# Отвечаем выбранной датой (chosen_date), а не абстрактным «день 1/2».
|
||||||
|
if [ "$TYPE" = "pkp" ] && [ -n "$PICKUP_DAY1" ]; then
|
||||||
|
play "ru/ss_pkp_intro"
|
||||||
|
DAYS=1
|
||||||
|
[ -n "$PICKUP_DAY2" ] && DAYS=2
|
||||||
|
[ -n "$PICKUP_DAY3" ] && DAYS=3
|
||||||
|
while [ -z "$OUTCOME" ]; do
|
||||||
|
digit "ru/ss_pkp_day${DAYS}" 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|3) [ "$CHOSEN_DAY" -le "$DAYS" ] || CHOSEN_DAY="" ;;
|
||||||
|
*) CHOSEN_DAY="" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$CHOSEN_DAY" ]; then
|
||||||
|
digit "ru/ss_pkp_day${DAYS}" 9000
|
||||||
|
CHOSEN_DAY=$DIGIT
|
||||||
|
case "$CHOSEN_DAY" in
|
||||||
|
1|2|3) [ "$CHOSEN_DAY" -le "$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="$PICKUP_DAY1" ;;
|
||||||
|
2) CHOSEN_DATE="$PICKUP_DAY2" ;;
|
||||||
|
3) CHOSEN_DATE="$PICKUP_DAY3" ;;
|
||||||
|
esac
|
||||||
|
DAY=$CHOSEN_DAY
|
||||||
|
save
|
||||||
|
# Половина дня: если осталось только «сегодня» и первая половина уже прошла — не спрашиваем.
|
||||||
|
if [ "$CHOSEN_DAY" = "1" ] && [ "$PICKUP_DAY1_FIRST" != "1" ]; then
|
||||||
|
HALF=2
|
||||||
|
play "ru/ss_ok_second"
|
||||||
|
else
|
||||||
|
digit "ru/ss_half_pkp" 9000
|
||||||
|
HALF=$DIGIT
|
||||||
|
if [ "$HALF" != "1" ] && [ "$HALF" != "2" ]; then
|
||||||
|
digit "ru/ss_half_pkp" 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"
|
||||||
|
fi
|
||||||
|
CONF_DAY=$CHOSEN_DAY; CONF_HALF=$HALF
|
||||||
|
OUTCOME=confirmed
|
||||||
|
play "ru/ss_tail_pkp"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# ─── выбор дня и времени, с возможностью переделать ──────────────────
|
# ─── выбор дня и времени, с возможностью переделать ──────────────────
|
||||||
while [ -z "$OUTCOME" ]; do
|
while [ -z "$OUTCOME" ]; do
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -97,7 +97,7 @@ def get_groups_to_call(conn, settings):
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
||||||
og.notification_status, og.delivery_status, og.sms_sent_at,
|
og.notification_status, og.delivery_status, og.sms_sent_at,
|
||||||
og.autocall_opt_out, og.created_at, og.delivery_type,
|
og.autocall_opt_out, og.created_at, og.delivery_type, og.town,
|
||||||
(SELECT count(*) FROM autocall_log l
|
(SELECT count(*) FROM autocall_log l
|
||||||
WHERE l.order_group_id = og.id) as attempt_count,
|
WHERE l.order_group_id = og.id) as attempt_count,
|
||||||
(SELECT count(*) FROM autocall_log l
|
(SELECT count(*) FROM autocall_log l
|
||||||
|
|
@ -121,6 +121,20 @@ def get_groups_to_call(conn, settings):
|
||||||
-- Не превышен лимит попыток
|
-- Не превышен лимит попыток
|
||||||
AND (SELECT count(*) FROM autocall_log l
|
AND (SELECT count(*) FROM autocall_log l
|
||||||
WHERE l.order_group_id = og.id) < %s
|
WHERE l.order_group_id = og.id) < %s
|
||||||
|
-- Гео-исключения: те же, что у 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 ('*', 'autocall_slot')
|
||||||
|
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 NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_city_profiles p
|
||||||
|
WHERE p.city = og.town AND p.sms_enabled = false
|
||||||
|
)
|
||||||
-- Самовывоз звоним только если разрешено настройкой
|
-- Самовывоз звоним только если разрешено настройкой
|
||||||
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
|
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
|
||||||
ORDER BY og.sms_sent_at ASC
|
ORDER BY og.sms_sent_at ASC
|
||||||
|
|
@ -151,6 +165,46 @@ def normalize_phone(phone):
|
||||||
clean = "7" + clean
|
clean = "7" + clean
|
||||||
return clean
|
return clean
|
||||||
|
|
||||||
|
def get_city_exclusion(conn, city):
|
||||||
|
"""Гео-исключение — общее для SMS и звонков.
|
||||||
|
|
||||||
|
Источник тот же, что у SMS: правила города (sms_city_rules) и общий
|
||||||
|
выключатель канала в профиле города (sms_city_profiles.sms_enabled).
|
||||||
|
Правило с campaign_type='*' действует на все каналы, 'autocall_slot' —
|
||||||
|
только на звонки.
|
||||||
|
|
||||||
|
Исключения по номеру (например, «не звонить на МТС», autocall_exclusions)
|
||||||
|
живут отдельно и на SMS не влияют — они про телефонию, а не про город.
|
||||||
|
"""
|
||||||
|
if not city:
|
||||||
|
return None
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT city, campaign_type, note
|
||||||
|
FROM sms_city_rules
|
||||||
|
WHERE city = %s
|
||||||
|
AND enabled = false
|
||||||
|
AND campaign_type IN ('*', 'autocall_slot')
|
||||||
|
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
|
||||||
|
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
|
||||||
|
LIMIT 1
|
||||||
|
""", (city,))
|
||||||
|
rule = cur.fetchone()
|
||||||
|
if rule:
|
||||||
|
return {
|
||||||
|
"source": "city_rule",
|
||||||
|
"reason": (rule.get("note") or "").strip() or f"город {city} исключён из рассылок",
|
||||||
|
}
|
||||||
|
cur.execute("SELECT sms_enabled, note FROM sms_city_profiles WHERE city = %s LIMIT 1", (city,))
|
||||||
|
profile = cur.fetchone()
|
||||||
|
if profile is not None and profile.get("sms_enabled") is False:
|
||||||
|
return {
|
||||||
|
"source": "city_profile",
|
||||||
|
"reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_exclusion_match(conn, phone):
|
def get_exclusion_match(conn, phone):
|
||||||
"""Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
|
"""Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
|
||||||
phone = normalize_phone(phone)
|
phone = normalize_phone(phone)
|
||||||
|
|
@ -168,6 +222,40 @@ def get_exclusion_match(conn, phone):
|
||||||
""", (phone, phone))
|
""", (phone, phone))
|
||||||
return cur.fetchone()
|
return cur.fetchone()
|
||||||
|
|
||||||
|
def pickup_window(sms_sent_at, now=None):
|
||||||
|
"""Даты, на которые предлагаем самовывоз.
|
||||||
|
|
||||||
|
Правило: забрать можно в день готовности (день попадания в базу — он же
|
||||||
|
день отправки ссылки) и два следующих дня, то есть максимум три дня
|
||||||
|
включая сегодняшний. Окно отсчитывается от дня готовности, а список
|
||||||
|
начинается с сегодня:
|
||||||
|
• готов сегодня → сегодня, завтра, послезавтра (3)
|
||||||
|
• готов вчера → сегодня, завтра (2)
|
||||||
|
• готов позавчера → только сегодня (1)
|
||||||
|
• готов раньше → окно закрыто, звонить не о чем (0)
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
tz = timezone(timedelta(hours=3))
|
||||||
|
today = now.astimezone(tz).date()
|
||||||
|
ready = (sms_sent_at or now).astimezone(tz).date()
|
||||||
|
last_allowed = ready + timedelta(days=2)
|
||||||
|
if last_allowed < today:
|
||||||
|
return []
|
||||||
|
dates = []
|
||||||
|
day = today
|
||||||
|
while day <= last_allowed and len(dates) < 3:
|
||||||
|
dates.append(day)
|
||||||
|
day += timedelta(days=1)
|
||||||
|
return [d.isoformat() for d in dates]
|
||||||
|
|
||||||
|
|
||||||
|
def is_today_before_noon(now=None):
|
||||||
|
"""До 12:00 по Симферополю — первую половину дня ещё можно предлагать."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
local = now.astimezone(timezone(timedelta(hours=3)))
|
||||||
|
return local.hour < 12
|
||||||
|
|
||||||
|
|
||||||
def resolve_call_type(group):
|
def resolve_call_type(group):
|
||||||
"""Тип звонка: pkp (самовывоз) или dlv (доставка).
|
"""Тип звонка: pkp (самовывоз) или dlv (доставка).
|
||||||
|
|
||||||
|
|
@ -202,6 +290,15 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
|
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
|
||||||
call_type = resolve_call_type(group)
|
call_type = resolve_call_type(group)
|
||||||
|
|
||||||
|
# Самовывоз: конкретные даты передаём в диалог явно — робот называет их
|
||||||
|
# голосом и возвращает выбранную дату, а не абстрактный «день 1/2».
|
||||||
|
pickup_days = []
|
||||||
|
if call_type == "pkp":
|
||||||
|
pickup_days = pickup_window(group.get("sms_sent_at") or group.get("created_at"))
|
||||||
|
if not pickup_days:
|
||||||
|
# Окно самовывоза закрыто (заказ готов более двух дней назад).
|
||||||
|
return None
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"Channel: PJSIP/{dial}@mmedia",
|
f"Channel: PJSIP/{dial}@mmedia",
|
||||||
f"CallerID: {CALLER_ID}",
|
f"CallerID: {CALLER_ID}",
|
||||||
|
|
@ -216,6 +313,10 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
f"Setvar: GROUP_ID={group.get('id') or ''}",
|
f"Setvar: GROUP_ID={group.get('id') or ''}",
|
||||||
f"Setvar: ATTEMPT={attempt_no}",
|
f"Setvar: ATTEMPT={attempt_no}",
|
||||||
f"Setvar: CUSTOMER_PHONE={phone}",
|
f"Setvar: CUSTOMER_PHONE={phone}",
|
||||||
|
f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 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_DAY1_FIRST={1 if (pickup_days and is_today_before_noon()) else 0}",
|
||||||
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}",
|
||||||
|
|
@ -226,6 +327,10 @@ def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
def push_call_file(group, settings, attempt_no, is_test_call=False):
|
def push_call_file(group, settings, attempt_no, is_test_call=False):
|
||||||
"""Кладёт call-файл на АТС через SSH."""
|
"""Кладёт call-файл на АТС через SSH."""
|
||||||
content = build_call_file(group, settings, attempt_no, is_test_call)
|
content = build_call_file(group, settings, attempt_no, is_test_call)
|
||||||
|
if content is None:
|
||||||
|
# Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем.
|
||||||
|
log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка")
|
||||||
|
return False
|
||||||
gid = str(group.get("id") or "test")[:8]
|
gid = str(group.get("id") or "test")[:8]
|
||||||
filename = f"ss-{gid}-{int(time.time())}.call"
|
filename = f"ss-{gid}-{int(time.time())}.call"
|
||||||
# Пишем файл на АТС через sshpass + cat
|
# Пишем файл на АТС через sshpass + cat
|
||||||
|
|
@ -264,7 +369,7 @@ def get_queued_calls(conn):
|
||||||
if ids:
|
if ids:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
||||||
og.created_at, og.delivery_type
|
og.created_at, og.delivery_type, og.town
|
||||||
FROM order_groups og WHERE og.id IN %s
|
FROM order_groups og WHERE og.id IN %s
|
||||||
""", (ids,))
|
""", (ids,))
|
||||||
og = {r["id"]: r for r in cur.fetchall()}
|
og = {r["id"]: r for r in cur.fetchall()}
|
||||||
|
|
@ -275,6 +380,7 @@ def get_queued_calls(conn):
|
||||||
r.setdefault("customer_phone", g.get("customer_phone"))
|
r.setdefault("customer_phone", g.get("customer_phone"))
|
||||||
r.setdefault("created_at", g.get("created_at"))
|
r.setdefault("created_at", g.get("created_at"))
|
||||||
r.setdefault("delivery_type", g.get("delivery_type"))
|
r.setdefault("delivery_type", g.get("delivery_type"))
|
||||||
|
r.setdefault("town", g.get("town"))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -300,6 +406,12 @@ def process_queue(conn, settings):
|
||||||
if not phone:
|
if not phone:
|
||||||
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
||||||
continue
|
continue
|
||||||
|
# Гео-исключение (как для SMS): город исключён целиком
|
||||||
|
city_excl = get_city_exclusion(conn, q.get("town"))
|
||||||
|
if city_excl:
|
||||||
|
log.info(f"Queue item {q['queue_id']} skipped by city rule ({q.get('town')}): {city_excl['reason']}")
|
||||||
|
mark_queue_processed(conn, q["queue_id"])
|
||||||
|
continue
|
||||||
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
||||||
excl = get_exclusion_match(conn, phone)
|
excl = get_exclusion_match(conn, phone)
|
||||||
if excl:
|
if excl:
|
||||||
|
|
@ -379,7 +491,8 @@ def backfill_no_answer(conn, settings):
|
||||||
if gid:
|
if gid:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT customer_phone_normalized, customer_phone, created_at, delivery_type
|
SELECT customer_phone_normalized, customer_phone, created_at,
|
||||||
|
delivery_type, town
|
||||||
FROM order_groups WHERE id = %s
|
FROM order_groups WHERE id = %s
|
||||||
""", (gid,))
|
""", (gid,))
|
||||||
grow = cur.fetchone() or {}
|
grow = cur.fetchone() or {}
|
||||||
|
|
@ -392,6 +505,9 @@ def backfill_no_answer(conn, settings):
|
||||||
# (статус «исключение» и так показывается из настроек).
|
# (статус «исключение» и так показывается из настроек).
|
||||||
if get_exclusion_match(conn, phone):
|
if get_exclusion_match(conn, phone):
|
||||||
continue
|
continue
|
||||||
|
# Гео-исключение: город, по которому звонки не делаем вовсе
|
||||||
|
if get_city_exclusion(conn, q.get("town")):
|
||||||
|
continue
|
||||||
scenario = resolve_scenario(group_created) if gid else "A"
|
scenario = resolve_scenario(group_created) if gid else "A"
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if gid:
|
if gid:
|
||||||
|
|
@ -453,6 +569,11 @@ def main():
|
||||||
pushed = 0
|
pushed = 0
|
||||||
for group in groups:
|
for group in groups:
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
|
||||||
|
# Гео-исключение (то же, что для SMS): город исключён целиком
|
||||||
|
city_excl = get_city_exclusion(conn, group.get("town"))
|
||||||
|
if city_excl:
|
||||||
|
log.info(f"Group {group['id']} skipped by city rule ({group.get('town')}): {city_excl['reason']}")
|
||||||
|
continue
|
||||||
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
||||||
excl = get_exclusion_match(conn, phone)
|
excl = get_exclusion_match(conn, phone)
|
||||||
if excl:
|
if excl:
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,16 @@ def get_groups_to_send(conn):
|
||||||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
||||||
AND COALESCE(scp.sms_enabled, true) = true
|
AND COALESCE(scp.sms_enabled, true) = true
|
||||||
|
-- Гео-исключения — общие для SMS и звонков: правило города
|
||||||
|
-- (campaign_type '*' — все каналы) блокирует и отправку 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 ('*', 'first_sms')
|
||||||
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,15 @@ def get_groups_to_manual(conn):
|
||||||
og.sms_attempts
|
og.sms_attempts
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
-- Гео-исключения — общие для 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_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
AND (
|
AND (
|
||||||
-- 1. second_sms_sent + срок истёк
|
-- 1. second_sms_sent + срок истёк
|
||||||
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,15 @@ def get_groups_to_send(conn):
|
||||||
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.delivery_status = 'paid_storage'
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
AND og.paid_storage_at IS NOT NULL
|
AND og.paid_storage_at IS NOT NULL
|
||||||
|
-- Гео-исключения — общие для 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 ('*', 'paid_storage')
|
||||||
|
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 COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,15 @@ def get_groups_to_send(conn):
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
|
||||||
AND COALESCE(scp.second_sms_enabled, true) = true
|
AND COALESCE(scp.second_sms_enabled, true) = true
|
||||||
|
-- Гео-исключения — общие для 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 ('*', 'second_sms')
|
||||||
|
AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE)
|
||||||
|
AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
-- Нет активной второй SMS в логе
|
-- Нет активной второй SMS в логе
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
|
||||||
|
|
@ -53,65 +53,55 @@ const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
|
/**
|
||||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
* Слоты самовывоза.
|
||||||
const hour = getCrimeaHour(referenceDate);
|
*
|
||||||
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
|
* Окно считаем от дня готовности (referenceDate — день попадания в базу, он же
|
||||||
|
* день отправки ссылки): забрать можно в день готовности и два следующих дня,
|
||||||
|
* максимум три дня включая сегодняшний. Отсчёт в списке — от фактического
|
||||||
|
* сегодня: если заказ готов вчера, останутся только «Сегодня» и «Завтра».
|
||||||
|
*/
|
||||||
|
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5], now = new Date()) => {
|
||||||
|
const todayKey = getCrimeaTodayKey(now);
|
||||||
|
const hour = getCrimeaHour(now);
|
||||||
|
const readyKey = getCrimeaTodayKey(referenceDate);
|
||||||
|
const lastAllowedKey = addDaysKey(readyKey, 2);
|
||||||
|
// Дата попадает в окно: не раньше сегодня и не позже «готовность + 2 дня».
|
||||||
|
const isInWindow = (dateKey) => Boolean(dateKey) && dateKey >= todayKey && dateKey <= lastAllowedKey;
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
if (isTodayWorkday) {
|
const addSlot = (dateKey, half, label, pickupType) => {
|
||||||
if (hour < 12) {
|
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${todayKey}-first`,
|
id: `pickup-${dateKey}-${half}`,
|
||||||
date: todayKey,
|
date: dateKey,
|
||||||
time: "Первая половина дня",
|
time: half === "first" ? "Первая половина дня" : "Вторая половина дня",
|
||||||
label: "Сегодня",
|
label,
|
||||||
pickupType: "today",
|
pickupType,
|
||||||
});
|
|
||||||
}
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${todayKey}-second`,
|
|
||||||
date: todayKey,
|
|
||||||
time: "Вторая половина дня",
|
|
||||||
label: "Сегодня",
|
|
||||||
pickupType: "today",
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сегодня: первая половина — только до 12:00, вторая — в течение дня.
|
||||||
|
if (isAllowedPickupDay(todayKey, pickupDays) && isInWindow(todayKey)) {
|
||||||
|
if (hour < 12) addSlot(todayKey, "first", "Сегодня", "today");
|
||||||
|
addSlot(todayKey, "second", "Сегодня", "today");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
const tomorrow = addDaysKey(todayKey, 1);
|
||||||
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
||||||
slots.push({
|
if (isInWindow(tomorrowWorkday)) {
|
||||||
id: `pickup-${tomorrowWorkday}-first`,
|
const label = getDeliveryRelativeDayLabel(tomorrowWorkday, now) || "Завтра";
|
||||||
date: tomorrowWorkday,
|
addSlot(tomorrowWorkday, "first", label, "tomorrow");
|
||||||
time: "Первая половина дня",
|
addSlot(tomorrowWorkday, "second", label, "tomorrow");
|
||||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
}
|
||||||
pickupType: "tomorrow",
|
|
||||||
});
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${tomorrowWorkday}-second`,
|
|
||||||
date: tomorrowWorkday,
|
|
||||||
time: "Вторая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(tomorrowWorkday, referenceDate) || "Завтра",
|
|
||||||
pickupType: "tomorrow",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
||||||
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
||||||
slots.push({
|
if (isInWindow(dayAfterWorkday)) {
|
||||||
id: `pickup-${dayAfterWorkday}-first`,
|
const label = getDeliveryRelativeDayLabel(dayAfterWorkday, now) || "Послезавтра";
|
||||||
date: dayAfterWorkday,
|
addSlot(dayAfterWorkday, "first", label, "dayAfter");
|
||||||
time: "Первая половина дня",
|
addSlot(dayAfterWorkday, "second", label, "dayAfter");
|
||||||
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
|
}
|
||||||
pickupType: "dayAfter",
|
|
||||||
});
|
|
||||||
slots.push({
|
|
||||||
id: `pickup-${dayAfterWorkday}-second`,
|
|
||||||
date: dayAfterWorkday,
|
|
||||||
time: "Вторая половина дня",
|
|
||||||
label: getDeliveryRelativeDayLabel(dayAfterWorkday, referenceDate) || "Послезавтра",
|
|
||||||
pickupType: "dayAfter",
|
|
||||||
});
|
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type AutocallBody = {
|
||||||
attempt_no?: number;
|
attempt_no?: number;
|
||||||
scenario?: string;
|
scenario?: string;
|
||||||
call_type?: string;
|
call_type?: string;
|
||||||
|
chosen_date?: string;
|
||||||
call_started_at?: string;
|
call_started_at?: string;
|
||||||
answered_at?: string | null;
|
answered_at?: string | null;
|
||||||
duration_sec?: number | null;
|
duration_sec?: number | null;
|
||||||
|
|
@ -51,6 +52,16 @@ function addDays(date: Date, days: number): string {
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** День по-человечески: сегодня / завтра / послезавтра, иначе дата дд.мм.гггг. */
|
||||||
|
function relativeDayLabel(dateKey: string): string {
|
||||||
|
const today = new Date();
|
||||||
|
for (const [offset, label] of [[0, "сегодня"], [1, "завтра"], [2, "послезавтра"]] as const) {
|
||||||
|
if (addDays(today, offset) === dateKey) return label;
|
||||||
|
}
|
||||||
|
const [y, m, d] = dateKey.split("-");
|
||||||
|
return y && m && d ? `${d}.${m}.${y}` : dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
const cors = getCorsHeaders(request, "private");
|
const cors = getCorsHeaders(request, "private");
|
||||||
|
|
@ -151,27 +162,36 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
// ── confirmed*: записываем слот ──
|
// ── confirmed*: записываем слот ──
|
||||||
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
|
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
|
||||||
const dayOffset = body.dtmf_day === 2 ? 2 : 1;
|
// День называет сам диалог (chosen_date) — робот перечисляет конкретные
|
||||||
const deliveryDate = addDays(new Date(), dayOffset);
|
// дни окна самовывоза, поэтому «1/2/3» само по себе дату не задаёт.
|
||||||
|
// Если дата не пришла (старые call-файлы) — считаем по цифре дня.
|
||||||
|
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
|
||||||
|
? (body.chosen_date as string)
|
||||||
|
: null;
|
||||||
|
const dayOffset = body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0;
|
||||||
|
const deliveryDate = chosenDate || addDays(new Date(), body.dtmf_day ? dayOffset : 1);
|
||||||
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
|
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
|
||||||
const dayLabel = body.dtmf_day ? DAY_LABEL[body.dtmf_day] : "завтра";
|
const dayLabel = chosenDate
|
||||||
|
? relativeDayLabel(chosenDate)
|
||||||
|
: body.dtmf_day
|
||||||
|
? DAY_LABEL[body.dtmf_day]
|
||||||
|
: "завтра";
|
||||||
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
|
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
delivery_date_source: "autocall",
|
delivery_date_source: "autocall",
|
||||||
delivery_status: "agreed",
|
delivery_status: "agreed",
|
||||||
notification_status: "confirmed",
|
notification_status: "confirmed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
called: true,
|
called: true,
|
||||||
called_at: new Date().toISOString(),
|
called_at: new Date().toISOString(),
|
||||||
called_comment: comment,
|
called_comment: comment,
|
||||||
next_notification_check_at: null,
|
next_notification_check_at: null,
|
||||||
};
|
};
|
||||||
|
if (timeSlot) update.delivery_time = timeSlot;
|
||||||
if (isPickup) {
|
if (isPickup) {
|
||||||
update.pickup_date = deliveryDate;
|
update.pickup_date = deliveryDate;
|
||||||
if (timeSlot) update.pickup_time_slot = timeSlot;
|
if (timeSlot) update.pickup_time_slot = timeSlot;
|
||||||
} else {
|
|
||||||
update.delivery_date = deliveryDate;
|
|
||||||
if (timeSlot) update.delivery_time = timeSlot;
|
|
||||||
}
|
}
|
||||||
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
|
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue