fix(sms): заказы больше не висят в «1-е приглашение отправлено» — в ручную работу переводим от последнего контакта и только по включённым каналам (SMS/автодозвон); заказ, где дата уже выбрана, роботу не звоним
This commit is contained in:
parent
e35b3d885f
commit
512aab9ff2
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -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": "Когда подключается менеджер",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,61 +118,129 @@ 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
|
||||
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_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'
|
||||
))
|
||||
-- 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
|
||||
""")
|
||||
ORDER BY c.last_contact_at ASC
|
||||
""", (
|
||||
int(manual_after_hours or 3),
|
||||
bool(channels.get("second_sms")),
|
||||
bool(channels.get("autocall")),
|
||||
))
|
||||
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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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("уже согласован");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue