fix(sms): заказы больше не висят в «1-е приглашение отправлено» — в ручную работу переводим от последнего контакта и только по включённым каналам (SMS/автодозвон); заказ, где дата уже выбрана, роботу не звоним

This commit is contained in:
Hermes 2026-09-16 17:15:43 +02:00
parent e35b3d885f
commit 512aab9ff2
9 changed files with 251 additions and 59 deletions

File diff suppressed because one or more lines are too long

View File

@ -211,6 +211,26 @@ SLIDES = [
"Если клиент уже согласовал слот, а потом начал менять и бросил трубку — прежний выбор сохраняется, ничего не теряется",
]},
},
{
"kind": "tablecard",
"title": "Клиент не выбрал дату после приглашения",
"sub": "Срок ожидания считаем от последнего контакта — SMS или звонка робота",
"table": {
"rows": [
["Что настроено в системе", "От чего считаем срок", "Когда заказ уходит менеджеру"],
["Только SMS-приглашение", "от последнего SMS", "через 3 ч (настройка в админке)"],
["Плюс автодозвон", "ждём звонок робота, отсчёт — от него", "через 3 ч после последнего звонка"],
["Плюс второе приглашение", "сначала ждём второе SMS, потом звонок", "после последнего контакта по последнему каналу"],
["Звонить нельзя (гео, исключение, «не звонить»)", "от последнего SMS", "через 3 ч — звонок не ждём"],
],
"widths": [250, 300, 318],
},
"full": {"title": "Что важно", "accent": ACCENT2, "items": [
"Ожидание включается только по каналам, которые реально включены в системе: если автодозвона нет — не ждём звонок",
"Позвонили позже — ожидание начинается заново: менеджер получает заказ, только когда клиент молчит по всем каналам",
"Клиент выбрал дату — из ожидания выходим сразу, даже если срок не истёк",
]},
},
{
"kind": "cards3full",
"title": "Когда подключается менеджер",

View File

@ -537,7 +537,8 @@ def get_queued_calls(conn):
if ids:
cur.execute("""
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
og.created_at, og.delivery_type, og.town
og.created_at, og.delivery_type, og.town,
og.notification_status, og.delivery_date, og.autocall_opt_out
FROM order_groups og WHERE og.id IN %s
""", (ids,))
og = {r["id"]: r for r in cur.fetchall()}
@ -549,6 +550,9 @@ def get_queued_calls(conn):
r.setdefault("created_at", g.get("created_at"))
r.setdefault("delivery_type", g.get("delivery_type"))
r.setdefault("town", g.get("town"))
r["notification_status"] = g.get("notification_status")
r["delivery_date"] = g.get("delivery_date")
r["autocall_opt_out"] = g.get("autocall_opt_out")
return rows
@ -586,6 +590,18 @@ def process_queue(conn, settings):
log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
mark_queue_processed(conn, q["queue_id"])
continue
# Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) —
# роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено.
# Заявку НЕ закрываем (правило владельца) — просто не обрабатываем.
agreed = (str(q.get("notification_status") or "") in ("confirmed", "manual_required", "opt_out")
or q.get("delivery_date") is not None)
if agreed and not is_test:
log.info(
f"Queue item {q['queue_id']} (group {group_id}): заказ уже согласован "
f"(статус {q.get('notification_status')}, дата {q.get('delivery_date')}) — "
"звонок не нужен, заявка остаётся в очереди"
)
continue
if group_id not in attempt_map:
if group_id:
with conn.cursor() as cur:

View File

@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine)
Заменяет n8n workflow "Ручное управление"
Логика:
Если после второй SMS (notification_status = 'second_sms_sent')
прошло auto_manual_after_hours и клиент не согласовал
Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч)
после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и
(delivery_status = 'pending_confirmation'):
→ notification_status = 'manual_required'
→ delivery_status = 'manual_confirmation_required'
→ next_notification_check_at = +3 мин
→ Telegram-уведомление
Ожидание считаем только по тем каналам, что реально включены в системе
(load_channels):
• второе приглашение включено → ждём, пока оно уйдёт;
• автодозвон включён и номеру звонить можно → ждём звонок, отсчёт
начнётся от него (позвонили позже — ожидание начинается заново);
• канал выключен / звонить нельзя (не звонить, гео, исключение) —
не ждём его и переводим по сроку ожидания.
Расписание: 9-21, Пн-Сб (настраивается из админки)
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
"""
@ -111,25 +118,88 @@ def is_within_work_hours(settings):
# ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_manual(conn):
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
def get_groups_to_manual(conn, channels=None, manual_after_hours=3):
"""Группы, где клиент не выбрал дату/время и пора в ручную работу.
Сценарии:
1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS
2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS
3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа
4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа
Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого
приглашения: если клиенту позвонили позже, ожидание начинается заново.
Каналы берём из настроек: ждём только те шаги, которые реально включены.
• второе приглашение включено → ждём, пока оно уйдёт;
• автодозвон включён и этому номеру звонить можно → ждём звонка;
• канал выключен (или звонить нельзя: не звонить/гео/исключение) —
не ждём его и переводим в ручную работу по сроку ожидания.
Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие
заказы в системе ведёт только менеджер, автоматика их не трогает.
"""
channels = channels or {}
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
cur.execute(r"""
WITH contact AS (
SELECT og.id,
GREATEST(
COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'),
COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'),
COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'),
COALESCE(og.called_at, TIMESTAMP 'epoch'),
COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l
WHERE l.order_group_id = og.id
AND COALESCE(l.status, '') IN
('sent','checking','delivered','send_failed','error','limit_exceeded')),
TIMESTAMP 'epoch'),
COALESCE((SELECT max(a.created_at) FROM autocall_log a
WHERE a.order_group_id = og.id), TIMESTAMP 'epoch')
) AS last_contact_at,
(SELECT count(*) FROM autocall_log a
WHERE a.order_group_id = og.id) AS call_attempts
FROM order_groups og
),
callable AS (
SELECT og.id,
(og.autocall_opt_out = false
AND COALESCE(og.customer_phone, '') <> ''
AND NOT EXISTS (
SELECT 1 FROM autocall_city_rules cr
WHERE cr.city = og.town AND cr.calls_enabled = false)
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules cr2
WHERE cr2.city = og.town AND cr2.enabled = false
AND cr2.campaign_type IN ('*', 'autocall_slot')
AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE)
AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE))
AND NOT EXISTS (
SELECT 1 FROM autocall_exclusions e
WHERE e.active = true
AND ((e.rule_type = 'phone'
AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
OR (e.rule_type = 'prefix'
AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10))
LIKE e.value || '%%')))
) AS can_call
FROM order_groups og
)
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.notification_status, og.delivery_link,
og.first_sms_sent_at, og.second_sms_sent_at,
og.sms_sent_at, og.next_notification_check_at,
og.sms_attempts
og.sms_attempts,
c.last_contact_at, c.call_attempts, cl.can_call
FROM order_groups og
JOIN contact c ON c.id = og.id
JOIN callable cl ON cl.id = og.id
WHERE og.delivery_status = 'pending_confirmation'
-- Гео-исключения — общие для SMS и звонков
-- Клиент уже выбрал дату/время — в ручную не переводим
AND og.delivery_date IS NULL
-- Контакта не было — отсчитывать не от чего
AND c.last_contact_at > TIMESTAMP 'epoch'
-- Срок ожидания истёк от последнего контакта
AND NOW() - c.last_contact_at >= make_interval(hours => %s)
-- Второе приглашение ждём, только если канал включён
AND (NOT %s OR og.second_sms_sent_at IS NOT NULL)
-- Звонок ждём, только если автодозвон включён и звонить можно
AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call)
-- Гео-исключения — общие для SMS и ручного управления
AND NOT EXISTS (
SELECT 1 FROM sms_city_rules r
WHERE r.city = og.town
@ -138,34 +208,39 @@ def get_groups_to_manual(conn):
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 (
-- 1. second_sms_sent + срок истёк
(COALESCE(og.notification_status, '') = 'second_sms_sent'
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
-- 2. first_sms_sent + срок истёк (>3h после отправки)
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
-- Иначе second_campaign не успеет отправить вторую SMS
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
AND og.second_sms_sent_at IS NOT NULL)
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
OR (COALESCE(og.notification_status, '') = 'link_ready'
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id
AND scl.status IN ('sent', 'checking')
AND scl.created_at > NOW() - INTERVAL '2 hours'
ORDER BY c.last_contact_at ASC
""", (
int(manual_after_hours or 3),
bool(channels.get("second_sms")),
bool(channels.get("autocall")),
))
-- 4. sms_sending/checking + >3h без доставки (зависло)
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
)
ORDER BY og.created_at ASC
""")
return [dict(r) for r in cur.fetchall()]
def load_channels(conn):
"""Какие каналы связи сейчас включены в системе.
`first_sms` — первое приглашение, `second_sms` — повторное SMS,
`autocall` — робот-автодозвон (настройки кампании 'autocall_slot').
От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги.
"""
channels = {"first_sms": True, "second_sms": False, "autocall": False}
try:
with conn.cursor() as cur:
cur.execute("""
SELECT campaign_type, enabled
FROM sms_campaign_settings
WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot')
""")
for campaign_type, enabled in cur.fetchall():
if campaign_type == "autocall_slot":
channels["autocall"] = bool(enabled)
else:
channels[campaign_type] = bool(enabled)
except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS
pass
return channels
def update_order_group(conn, group_id, fields):
with conn.cursor() as cur:
set_parts = []
@ -182,35 +257,55 @@ def update_order_group(conn, group_id, fields):
# ─── Основная логика ─────────────────────────────────────────────────────────
def step_move_to_manual(conn, settings):
def step_move_to_manual(conn, settings, channels=None):
"""Перевод групп в ручное управление"""
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
delay_minutes = settings.get("auto_manual_after_hours", 3)
wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3)
channels = channels or load_channels(conn)
groups = get_groups_to_manual(conn)
log.info(f"Step 1: {len(groups)} groups to move to manual")
groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours)
log.info(
f"Step 1: {len(groups)} groups to move to manual "
f"(ожидание {wait_hours} ч от последнего контакта; каналы: "
f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, "
f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, "
f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})"
)
moved = 0
dry_run = os.environ.get("DRY_RUN") == "1"
for group in groups:
group_id = str(group["id"])
name = group.get("customer_name") or group.get("group_key", "—")
phone = group.get("customer_phone", "")
delivery_link = group.get("delivery_link", "")
attempts = group.get("sms_attempts") or 1
log.info(f"Group {group_id}: moving to manual_required ({name})")
if dry_run:
log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})")
moved += 1
continue
log.info(
f"Group {group_id}: moving to manual_required ({name}); "
f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}"
)
update_order_group(conn, group_id, {
"notification_status": "manual_required",
"delivery_status": "manual_confirmation_required",
"sms_attempts": 2,
# Попытки НЕ подменяем: показываем столько, сколько реально отправлено
"sms_attempts": attempts,
"last_sms_error": None,
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
# Дальше ждать нечего — проверки по этому заказу закончились
"next_notification_check_at": None,
"status": "manual_required",
})
send_telegram(
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
f"Клиент не согласовал доставку после двух SMS\n"
f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта "
f"({'звонки' if channels.get('autocall') else 'SMS'})\n"
f"Ссылка: {delivery_link}",
tg_chat,
)
@ -272,8 +367,9 @@ def main():
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
conn.commit()
log.info("RESTART: manual campaign — will process all eligible groups now")
else:
moved = step_move_to_manual(conn, settings)
channels = load_channels(conn)
moved = step_move_to_manual(conn, settings, channels=channels)
log.info(f"Run summary: moved_to_manual={moved}")

View File

@ -511,7 +511,7 @@ export const SmsCampaignPanel = () => {
)}
</>
)}
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
<SettingField label="В ручную работу через (часов после последнего контакта)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} hint="Отсчёт от последнего контакта: SMS или звонок робота. Если автодозвон включён — ждём звонок, если включено второе SMS — ждём его." />
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
</div>
@ -804,7 +804,7 @@ export const SmsCampaignPanel = () => {
};
// ── Helpers ──────────────────────────────────────────────────────────────────
const SettingField = ({ label, value, onChange }) => (
const SettingField = ({ label, value, onChange, hint }) => (
<label className="block">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input
@ -813,6 +813,7 @@ const SettingField = ({ label, value, onChange }) => (
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
{hint ? <span className="mt-1 block text-[10px] leading-snug text-[var(--color-text-muted)]">{hint}</span> : null}
</label>
);

View File

@ -161,9 +161,23 @@ export const AutocallStatusCard = ({ order, userRole }) => {
}, [order?.id, order?.delivery_type, order?.created_at]);
const autocallBlocked = availability ? !availability.possible : false;
// Заказ уже согласован — дату выбрал клиент (в том числе в звонке) или поставил менеджер
const orderAgreed = Boolean(order?.delivery_date || order?.deliveryDate)
|| ["confirmed", "manual_required", "opt_out"].includes(
String(order?.notification_status || order?.notificationStatus || ""),
);
const process = useMemo(
() => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }),
[recentQueue, callAfter, settings, callUnreachable],
() => describeCallProcess({
queueItem: recentQueue,
callAfter,
settings: {
...(settings || {}),
call_unreachable: callUnreachable,
order_agreed: orderAgreed,
},
}),
[recentQueue, callAfter, settings, callUnreachable, orderAgreed],
);
const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing";
@ -338,17 +352,28 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{/* Кнопки */}
{canManage && (
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
{autocallBlocked && (
{autocallBlocked && !orderAgreed && (
<div className="mb-2 rounded-lg bg-[rgba(245,158,11,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
🚫 Автодозвон недоступен: {availability?.reason || "по графику роботу нечего предложить"}. Выберите день вручную или позвоните сами.
</div>
)}
{orderAgreed && (
<div className="mb-2 rounded-lg bg-[var(--color-surface-strong)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-muted)]">
Заказ уже согласован — автодозвон не нужен. Если надо переспросить клиента, позвоните сами.
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={handleCallNow}
disabled={sending || processing || optOut || autocallBlocked}
title={autocallBlocked ? (availability?.reason || "Автодозвон недоступен") : undefined}
disabled={sending || processing || optOut || autocallBlocked || orderAgreed}
title={
orderAgreed
? "Заказ уже согласован — звонок робота не нужен"
: autocallBlocked
? (availability?.reason || "Автодозвон недоступен")
: undefined
}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
>
{sending

View File

@ -130,6 +130,18 @@ export const describeCallProcess = ({ queueItem, callAfter, settings = {}, now =
};
}
// Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам):
// звонок не нужен и планировщик заявку не обрабатывает — говорим честно.
// Заявку при этом НЕ закрываем и НЕ удаляем (правило владельца).
if (settings.order_agreed) {
return {
phase: "pending",
tone: "neutral",
title: "Звонок не потребуется — заказ уже согласован",
hint: `Дата по заказу уже выбрана, заявка так и останется невыполненной — ${requested}`,
};
}
const soon = isCallTimeNow({ work_hours_start: startHour, work_hours_end: endHour, work_days: workDays }, now);
const ageMinutes = (now.getTime() - new Date(queueItem.requested_at).getTime()) / 60000;

View File

@ -137,4 +137,26 @@ describe("describeCallProcess — что видит логист", () => {
expect(res.tone).toBe("warning");
expect(res.hint).toContain("тестовый номер");
});
it("заказ уже согласован — заявку не обрабатываем и говорим об этом честно", () => {
const res = describeCallProcess({
queueItem,
callAfter: null,
settings: { ...SETTINGS, order_agreed: true },
now: msk("2026-09-15T10:40:00"),
});
expect(res.phase).toBe("pending");
expect(res.title).toContain("уже согласован");
expect(res.hint).toContain("останется невыполненной");
});
it("согласованный заказ: без флага order_agreed состояние обычное", () => {
const res = describeCallProcess({
queueItem,
callAfter: null,
settings: SETTINGS,
now: msk("2026-09-15T10:40:00"),
});
expect(res.title).not.toContain("уже согласован");
});
});