diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index db22bd9..1bacd2b 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -172,7 +172,7 @@ def send_telegram(message, chat_id): # ─── State Machine ─────────────────────────────────────────────────────────── -def get_groups_to_send(conn): +def get_groups_to_send(conn, max_attempts): """Группы, которым ещё не отправили SMS. Режимы (из sms_city_profiles): @@ -183,7 +183,7 @@ def get_groups_to_send(conn): cur.execute(""" SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.customer_phone_normalized, og.delivery_link, og.notification_status, - og.delivery_type, og.town, + og.delivery_type, og.town, og.sms_attempts, -- Профиль города (fallback на дефолтные значения если нет записи) COALESCE(scp.delivery_mode, 'link') as delivery_mode, COALESCE(scp.delivery_text, '') as delivery_text, @@ -228,8 +228,15 @@ def get_groups_to_send(conn): AND scl.status IN ('sent', 'checking') AND scl.created_at > NOW() - INTERVAL '2 hours' ) + -- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку, + -- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны) + AND ( + SELECT count(*) FROM sms_campaign_log scl2 + WHERE scl2.order_group_id = og.id + AND scl2.campaign_type = 'first_sms' + ) < %s ORDER BY og.created_at ASC - """) + """, (max_attempts,)) return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): @@ -310,6 +317,22 @@ def update_order_group(conn, group_id, fields): cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values) conn.commit() +def count_sms_attempts(conn, group_id, campaign_type="first_sms"): + """Сколько раз эту SMS уже пытались отправить (строк в логе этой кампании). + + Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт + новую строку, поэтому в самой строке всегда было бы attempts=1, условие + «attempts < max_attempts» выполнялось бы вечно и кампания слала бы SMS + каждые 5 минут бесконечно (проверено на заказе ТП-010543: 457 отправок за + 3,5 суток, ни одна не доставлена, в ручную работу заказ не ушёл). + """ + with conn.cursor() as cur: + cur.execute(""" + SELECT COUNT(*) FROM sms_campaign_log + WHERE order_group_id = %s AND campaign_type = %s + """, (group_id, campaign_type)) + return cur.fetchone()[0] + # ─── Основная логика ───────────────────────────────────────────────────────── def step_send_new(conn, settings): @@ -319,7 +342,7 @@ def step_send_new(conn, settings): max_attempts = settings.get("max_attempts", 2) second_sms_delay = settings.get("second_sms_delay_hours", 3) - groups = get_groups_to_send(conn) + groups = get_groups_to_send(conn, max_attempts) log.info(f"Step 1: {len(groups)} groups to send SMS") sent_count = 0 @@ -363,6 +386,8 @@ def step_send_new(conn, settings): log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})") + attempt_no = count_sms_attempts(conn, group_id) + 1 + log.info(f"Group {group_id}: attempt {attempt_no}/{max_attempts}") sms_id, raw, code = send_sms(send_phone, sms_text, api_id) if sms_id: @@ -374,7 +399,7 @@ def step_send_new(conn, settings): sms_text=sms_text, status="sent", sms_code=code, - attempts=1, + attempts=attempt_no, ) # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status if sms_mode == "notification": @@ -384,6 +409,7 @@ def step_send_new(conn, settings): "sms_sent_at": "NOW()", "first_sms_sent_at": "NOW()", "status": "first_sms_sent", + "sms_attempts": attempt_no, }) log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)") else: @@ -391,6 +417,7 @@ def step_send_new(conn, settings): update_order_group(conn, group_id, { "notification_status": "sms_sending", "sms_sent_at": "NOW()", + "sms_attempts": attempt_no, }) log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending") sent_count += 1 @@ -404,7 +431,7 @@ def step_send_new(conn, settings): sms_text=sms_text, status="send_failed", sms_code=code, - attempts=1, + attempts=attempt_no, error_message=error, ) log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}") @@ -412,6 +439,7 @@ def step_send_new(conn, settings): update_order_group(conn, group_id, { "notification_status": "send_failed", "last_sms_error": error[:200], + "sms_attempts": attempt_no, }) send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat) @@ -435,7 +463,7 @@ def step_check_status(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 was_needs_check = item.get("needs_check", False) code, raw, api_code = check_sms_status(sms_id, api_id) @@ -481,9 +509,11 @@ def step_check_status(conn, settings): }) else: update_order_group(conn, group_id, { - "notification_status": "send_failed", - "last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})", + "notification_status": "manual_required", + "last_sms_error": f"SMS не доставлена после {max_attempts} попыток (код {code})", }) + log.error(f"Group {group_id}: attempts exhausted ({attempts}/{max_attempts}), " + f"notification_status→manual_required") send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat) elif code in LIMIT_ERROR_CODES: @@ -519,7 +549,7 @@ def step_handle_expired(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 delivery_link = item.get("delivery_link", "") log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") @@ -544,6 +574,63 @@ def step_handle_expired(conn, settings): tg_chat, ) +def step_release_exhausted(conn, settings): + """Шаг 4: группы, у которых попытки исчерпаны, а статус остался 'link_ready'. + + Такое состояние возникает как следствие сбоя счётчика: группа выпадает из + выборки по лимиту попыток, но из очереди не уходит — висит в 'link_ready' + вечно, клиент не получает ссылку, в ручную работу заказ не переводится. + Шаг закрывает эту дыру: попытки кончились → «нужен менеджер». + """ + max_attempts = settings.get("max_attempts", 2) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT og.id, og.group_key, og.customer_name, og.customer_phone, + (SELECT count(*) FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND l.campaign_type = 'first_sms') AS attempts + FROM order_groups og + WHERE og.delivery_status = 'pending_confirmation' + AND COALESCE(og.notification_status, '') = 'link_ready' + AND ( + SELECT count(*) FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND l.campaign_type = 'first_sms' + ) >= %s + -- Активная отправка в работе — не мешаем ей + AND NOT EXISTS ( + SELECT 1 FROM sms_campaign_log l2 + WHERE l2.order_group_id = og.id + AND l2.campaign_type = 'first_sms' + AND l2.status IN ('sent', 'checking') + ) + """, (max_attempts,)) + groups = [dict(r) for r in cur.fetchall()] + + for group in groups: + group_id = str(group["id"]) + name = group.get("customer_name") or group.get("group_key", "—") + phone = group.get("customer_phone", "") + attempts = group.get("attempts") or max_attempts + + log.warning(f"Group {group_id}: попыток {attempts}/{max_attempts}, статус остался link_ready " + f"→ manual_required (клиент SMS не получил)") + update_order_group(conn, group_id, { + "notification_status": "manual_required", + "last_sms_error": f"SMS не доставлена после {attempts} попыток — нужен менеджер", + }) + send_telegram( + f"🔧 Нужен менеджер\n{name} ({phone})\n" + f"SMS не доставлена после {attempts} попыток — клиенту не ушла ссылка", + tg_chat, + ) + + if groups: + log.info(f"Step 4: {len(groups)} groups released from link_ready → manual_required") + return len(groups) + # ─── Main ──────────────────────────────────────────────────────────────────── def main(): @@ -565,8 +652,9 @@ def main(): sent = step_send_new(conn, settings) delivered = step_check_status(conn, settings) step_handle_expired(conn, settings) + released = step_release_exhausted(conn, settings) - log.info(f"Run summary: sent={sent}, delivered={delivered}") + log.info(f"Run summary: sent={sent}, delivered={delivered}, released_to_manual={released}") if sent > 0 or delivered > 0: send_telegram( diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index 85f6770..2861530 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -207,7 +207,7 @@ def send_telegram(message, chat_id): # ─── State Machine ─────────────────────────────────────────────────────────── -def get_groups_to_send(conn): +def get_groups_to_send(conn, max_attempts): """Группы, где первая SMS доставлена, но клиент не согласовал, и пришло время для второй SMS (next_notification_check_at <= NOW()). second_sms_sent_at IS NULL — вторая SMS ещё не отправлена. @@ -243,8 +243,15 @@ def get_groups_to_send(conn): AND scl.status IN ('sent', 'checking') AND scl.created_at > NOW() - INTERVAL '2 hours' ) + -- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку, + -- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны) + AND ( + SELECT count(*) FROM sms_campaign_log scl2 + WHERE scl2.order_group_id = og.id + AND scl2.campaign_type = 'second_sms' + ) < %s ORDER BY og.created_at ASC - """) + """, (max_attempts,)) return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): @@ -327,6 +334,21 @@ def update_order_group(conn, group_id, fields): # ─── Проверка рабочего времени ──────────────────────────────────────────────── +def count_sms_attempts(conn, group_id, campaign_type=CAMPAIGN_TYPE): + """Сколько раз эту SMS уже пытались отправить (строк в логе кампании). + + Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт + новую строку, поэтому в строке всегда было бы attempts=1, условие + «attempts < max_attempts» выполнялось бы вечно и вторая SMS уходила бы + каждые 5 минут бесконечно. + """ + with conn.cursor() as cur: + cur.execute(""" + SELECT COUNT(*) FROM sms_campaign_log + WHERE order_group_id = %s AND campaign_type = %s + """, (group_id, campaign_type)) + return cur.fetchone()[0] + def is_within_work_hours(settings): """Проверка: сейчас рабочие часы. settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') @@ -354,8 +376,9 @@ def step_send_new(conn, settings): """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования""" api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + max_attempts = settings.get("max_attempts", 2) - groups = get_groups_to_send(conn) + groups = get_groups_to_send(conn, max_attempts) log.info(f"Step 1: {len(groups)} groups to send second SMS") sent_count = 0 @@ -387,6 +410,8 @@ def step_send_new(conn, settings): log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})") + attempt_no = count_sms_attempts(conn, group_id) + 1 + log.info(f"Group {group_id}: second SMS attempt {attempt_no}/{max_attempts}") sms_id, raw, code = send_sms(send_phone, sms_text, api_id) if sms_id: @@ -398,7 +423,7 @@ def step_send_new(conn, settings): sms_text=sms_text, status="sent", sms_code=code, - attempts=1, + attempts=attempt_no, ) # ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending update_order_group(conn, group_id, { @@ -416,7 +441,7 @@ def step_send_new(conn, settings): sms_text=sms_text, status="send_failed", sms_code=code, - attempts=1, + attempts=attempt_no, error_message=error, ) log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}") @@ -445,7 +470,7 @@ def step_check_status(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 code, raw, api_code = check_sms_status(sms_id, api_id) @@ -527,7 +552,7 @@ def step_handle_expired(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") update_sms_log(conn, log_id, status="expired",